← All reports

[2] CSP empty object-src source list treated as permissive for URL-less plugins

MediumWebCore Content Security Policy enforcementCrossOrigin

The object-src spelling that looks like deny-all was the permissive one.

c6228ab

Rated Medium — two policies the spec defines as identical produced opposite decisions, and the permissive one was the deny-all-looking spelling. No memory primitive; the cost is that a CSP deployed specifically to kill plugin instantiation didn't.

Content Security Policy lets a document declare, per resource type, which sources may be loaded; the object-src directive is the gate for <object> and <embed> plugin loads. The right-hand side of a directive is a source list — it may hold tokens like 'self' or 'none', scheme and host expressions, or nothing at all. CSP Level 3 specifies that an empty source list matches no URL, making object-src; semantically identical to object-src 'none', and both should forbid any plugin instantiation.

The angle: on a page hardened with object-src;, an attacker with an HTML injection sink can inject a <object type="..."> or <embed type="..."> carrying no data/src and still get the default plugin for that type instantiated.

When an <object> or <embed> element has no data/src attribute, WebKit previously passed an empty URL to the CSP check with special-case logic that only blocked for the literal 'none' keyword. An empty source list (object-src;) was incorrectly allowed despite being equivalent to 'none' per CSP Level 3 §6.7.2.7.

Remove the special-case handling from §6.1.9 entirely. Instead, use the document's own URL as a fallback for source list matching when the element has no associated URL. The document URL will naturally fail to match empty source lists and 'none' (blocked), but will match 'self' or wildcard (allowed).

Source/WebCore/page/csp/ContentSecurityPolicySourceListDirective.cpp

-bool ContentSecurityPolicySourceListDirective::allows(const URL& url, bool didReceiveRedirectResponse, ShouldAllowEmptyURLIfSourceListIsNotNone shouldAllowEmptyURLIfSourceListEmpty)
+bool ContentSecurityPolicySourceListDirective::allows(const URL& url, bool didReceiveRedirectResponse)
{
if (url.isEmpty())
- return shouldAllowEmptyURLIfSourceListEmpty == ShouldAllowEmptyURLIfSourceListIsNotNone::Yes && !m_sourceList.isNone();
+ return false;
return m_sourceList.matches(url, didReceiveRedirectResponse);
}

Source/WebCore/page/csp/ContentSecurityPolicy.cpp

- if (m_policies.isEmpty() || LegacySchemeRegistry::schemeShouldBypassContentSecurityPolicy(url.protocol()))
+ const auto& urlToCheck = url.isEmpty() ? m_protectedURL : url;
+ if (m_policies.isEmpty() || LegacySchemeRegistry::schemeShouldBypassContentSecurityPolicy(urlToCheck.protocol()))
return true;
- // ... 'MUST be blocked if object-src's value is 'none', but will otherwise be allowed' ...
String sourceURL;
- const auto& blockedURL = !preRedirectURL.isNull() ? preRedirectURL : url;
+ const auto& blockedURL = !preRedirectURL.isNull() ? preRedirectURL : urlToCheck;
...
- return allPoliciesAllow(handleViolatedDirective, &ContentSecurityPolicyDirectiveList::violatedDirectiveForObjectSource, url, redirectResponseReceived == RedirectResponseReceived::Yes, ContentSecurityPolicySourceListDirective::ShouldAllowEmptyURLIfSourceListIsNotNone::Yes);
+ return allPoliciesAllow(handleViolatedDirective, &ContentSecurityPolicyDirectiveList::violatedDirectiveForObjectSource, urlToCheck, redirectResponseReceived == RedirectResponseReceived::Yes);

LayoutTests/imported/w3c/web-platform-tests/content-security-policy/object-src/object-src-no-url-empty-source-list-blocked.html

+<meta http-equiv="Content-Security-Policy" content="object-src; script-src 'self' 'unsafe-inline';">
+<object type="text/html"></object>

The change removes the ShouldAllowEmptyURLIfSourceListIsNotNone special case from source-list matching and replaces it with a URL substitution at the caller. ContentSecurityPolicySourceListDirective::allows() now returns false unconditionally when the URL is empty, instead of consulting !m_sourceList.isNone(); the parameter disappears from the signature, from checkSource(), from violatedDirectiveForObjectSource(), and from the related allows() overload, and the two checkFrameAncestors() overloads drop the now-removed third argument. In ContentSecurityPolicy::allowObjectFromSource(), an empty incoming url — the <object>/<embed> with no data/src — is replaced by the document's own m_protectedURL, and that substituted URL is used for the scheme bypass check, for the reported blocked URL, and as the URL passed to allPoliciesAllow. Three new WPT tests assert that <object>/<embed> without a URL are blocked under both object-src 'none' and object-src;.

Conflation of two CSP states ('none' keyword vs. an empty source list) in the empty-URL fast path of source-list matching, causing an empty source list to behave permissively rather than as a deny-all.

What CSP gates. Content Security Policy is an HTTP-header- or <meta http-equiv>-delivered policy that lets a document restrict which sources may be loaded for each resource type. object-src governs <object>/<embed> plugin loads; allowObjectFromSource() is WebKit's gate for those loads and is called from HTMLPlugInElement, CachedResourceLoader, and PolicyChecker.

Source lists and their two "empty" spellings. A source list is the right-hand side of a directive. It can hold tokens like 'self', 'none', or scheme/host expressions, or it can be empty. CSP Level 3 specifies that an empty source list matches no URL, behaving identically to 'none'. WebKit models a source list with ContentSecurityPolicySourceList; isNone() returns true only for the literal 'none' token, and an empty list returns false.

Plugin elements with no associated URL. <object> and <embed> may instantiate a plugin with no associated URL: when the element has neither data nor src, only the type attribute selects the plugin. This case prompted a special clause in early CSP drafts (§6.1.9) that allowed such no-URL plugins unless 'none' was specified — a clause the cleaner Level 3 model no longer needs.

The root cause is a logic error in the empty-URL fast path of source-list matching. Before the fix, ContentSecurityPolicySourceListDirective::allows() short-circuited on an empty URL with the predicate shouldAllowEmptyURLIfSourceListEmpty == Yes && !m_sourceList.isNone(), implementing the §6.1.9 special case cited in the now-removed comment: a plugin with no URL is blocked only by 'none', otherwise allowed.

That predicate conflates two distinct CSP states. m_sourceList.isNone() is true only for the literal 'none' token, so an empty source list evaluates it as false and the predicate becomes Yes && !false = true:

  Policy                 isNone()   empty-URL fast path      Spec says
  ────────────────────   ────────   ─────────────────────    ─────────
  object-src 'none';      true      Yes && !true  = false     block  ✓
  object-src;             false     Yes && !false = true      block  ✗ allowed
  object-src 'self';      false     Yes && !false = true      allow  ✓

The middle row is the bug: a policy of object-src; permitted an <object>/<embed> with no data/src to load its default plugin, even though the author wrote the policy to forbid all object sources. The claim that isNone() is true only for the literal token is the crux of the mechanism and is drawn from the removed predicate's own semantics; ContentSecurityPolicySourceList's implementation is not part of the supplied context.

The fix takes the special case out of the matcher entirely and moves the handling up one level. With allows() returning false for any empty URL, allowObjectFromSource() substitutes the document's own protected URL before matching. That substituted URL then flows through the ordinary matcher: it fails to match 'none' and fails to match an empty list (neither matches anything), but succeeds against 'self' or a wildcard — reproducing the intended §6.1.9 outcome for permissive policies without a bespoke branch.

Exploitation is a policy bypass, not a memory-safety issue. On a page protected by Content-Security-Policy: object-src;, an attacker with an HTML injection sink injects <object type="..."></object> or <embed type="..."> with no data/src attribute; before the fix the empty-URL path returned true and allowObjectFromSource() permitted the instantiation, so the plugin selected by the type attribute could run despite the deny-all policy. The result is broader plugin attack surface available to follow-on exploits within the WebContent process — not a sandbox escape, since it requires an existing injection vector in a CSP-protected page and only relaxes which plugins can be instantiated under that page's policy.

The discovery angle looks like WPT conformance work: the new tests land under imported/w3c/web-platform-tests/content-security-policy/object-src/ and assert behaviour explicitly required by CSP Level 3 §6.7.2.7, which fits either importing upstream WPT and observing WebKit failures, or a reviewer noticing that the §6.1.9 comment referenced an editor's draft that had since been superseded. The Level 3 §6.7.2.7 citation itself is relayed from the commit message.

This vulnerability weakens the CSP object-src defense-in-depth boundary. The spec assumes object-src; and object-src 'none' are interchangeable — both forbid any plugin instantiation — and before the fix that invariant was violated on the no-URL plugin path, undermining a mitigation often deployed specifically to neuter object/embed-based exploitation.

Insight: the bug is a fossil. CSP Level 1/2 carried a literal §6.1.9 special case for no-URL plugin elements that pre-dated the cleaner Level 3 model where an empty source list is uniformly deny-all; carrying that legacy case into the matcher created a state where two semantically equivalent policies produced different decisions. The fix's strategy — substitute the document's own URL when the resource URL is empty — is worth borrowing: it lets the same matcher handle no-URL cases without bespoke logic, because the document URL naturally fails 'none' and empty lists while succeeding 'self' and wildcards.