← All issues

[6] CSP Port-Matching Bypass via Unconditional Port-Equivalence Relaxation

Severity: Medium | Component: WebCore CSP | c82619c

Rated Medium because the observable effect is a CSP policy bypass allowing resources from port 443 to match a source expression intended to restrict to port 80, but the practical impact is limited by the rarity of CSP configurations that specify port 80 on an HTTPS source expression. Confidence is 0.92 based on the clearly demonstrated information-loss mechanism in schemeMatches → portMatches.

The patch refactors schemeMatches() to return a SchemeMatchResult enum (NoMatch, Match, InsecureUpgradeMatch) instead of a plain bool, and threads this information into portMatches() via a ShouldUpgradePorts parameter. The port-equivalence logic (treating port 80 and 443 as interchangeable defaults) now only fires when an actual insecure-to-secure scheme upgrade occurred.

Source/WebCore/page/csp/ContentSecurityPolicySource.cpp

- bool ContentSecurityPolicySource::schemeMatches(const URL& url) const
+ SchemeMatchResult ContentSecurityPolicySource::schemeMatches(const URL& url) const
{
// https://www.w3.org/TR/CSP3/#match-schemes.
- auto& scheme = m_scheme.isEmpty() ? m_policy->selfProtocol() : m_scheme;
+ const auto& scheme = m_scheme.isEmpty() ? m_policy->selfProtocol() : m_scheme;
auto urlScheme = url.protocol();
 
+ // Step 1.1: A matches B.
if (scheme == urlScheme)
- return true;
+ return SchemeMatchResult::Match;
 
- // host-sources can do direct-upgrades.
+ // Step 1.2: A is "http" and B is "https".
if (scheme == "http"_s && urlScheme == "https"_s)
- return true;
- if (scheme == "ws"_s && (urlScheme == "wss"_s || urlScheme == "https"_s || urlScheme == "http"_s))
- return true;
- if (scheme == "wss"_s && urlScheme == "https"_s)
- return true;
+ return SchemeMatchResult::InsecureUpgradeMatch;
+
+ // Step 1.3: A is "ws" and B is "wss", "http", or "https".
+ if (scheme == "ws"_s) {
+ if (urlScheme == "wss"_s || urlScheme == "https"_s)
+ return SchemeMatchResult::InsecureUpgradeMatch;
+ if (urlScheme == "http"_s)
+ return SchemeMatchResult::Match;
+ }
+
+ // Step 1.4: A is "wss" and B is "https".
+ if (scheme == "wss"_s && urlScheme == "https"_s)
+ return SchemeMatchResult::Match;
- bool ContentSecurityPolicySource::portMatches(const URL& url) const
+ bool ContentSecurityPolicySource::portMatches(const URL& url, ShouldUpgradePorts shouldUpgradePorts) const
{
...
- auto defaultSecurePort = WTF::defaultPortForProtocol("https"_s).value_or(443);
- auto defaultInsecurePort = WTF::defaultPortForProtocol("http"_s).value_or(80);
- bool isUpgradeSecure = (port == defaultSecurePort) || (!port && (url.protocol() == "https"_s || url.protocol() == "wss"_s));
- bool isCurrentUpgradable = (m_port == defaultInsecurePort) || (m_scheme == "http"_s && (!m_port || m_port == defaultSecurePort));
- if (isUpgradeSecure && isCurrentUpgradable)
- return true;
+ if (shouldUpgradePorts == ShouldUpgradePorts::Yes) {
+ auto defaultSecurePort = WTF::defaultPortForProtocol("https"_s).value_or(443);
+ auto defaultInsecurePort = WTF::defaultPortForProtocol("http"_s).value_or(80);
+ bool urlOnSecureDefaultPort = (urlPort == defaultSecurePort) || (!urlPort && (url.protocol() == "https"_s || url.protocol() == "wss"_s));
+ bool sourcePortIsUpgradable = !m_port || m_port == defaultInsecurePort || m_port == defaultSecurePort;
+ if (urlOnSecureDefaultPort && sourcePortIsUpgradable)
+ return true;
+ }

LayoutTests/.../script-src-parsing-implicit-and-explicit-port-number.html

+ // Tests that HTTPS URL does not match HTTPS source expression with opposite scheme's default port number.
+ ["no", "script-src https://127.0.0.1:8000", "https://127.0.0.1:8443/..."],
+
+ // Tests that HTTPS URL does not match WSS source expression with HTTP default port (secure scheme, no port upgrade).
+ ["no", "script-src wss://127.0.0.1:8000", "https://127.0.0.1:8443/..."],

Lost information flow between scheme matching and port matching causes a CSP port-equivalence relaxation to fire outside its intended precondition.

Content Security Policy (CSP) source expressions specify allowed origins for resource loading. A directive like script-src https://host:80 should only allow scripts from that exact host and port. WebKit implements "scheme upgrade" logic per the CSP3 spec: when a CSP source uses an insecure scheme (http) but the URL uses the secure equivalent (https), the match is allowed and default ports are treated as equivalent (80↔443) since they differ across schemes. This port equivalence should only apply when an actual insecure-to-secure scheme transition occurs. ContentSecurityPolicySource::matches() calls schemeMatches(), hostMatches(), portMatches(), and pathMatches() in sequence.

Before the fix, portMatches() unconditionally applied port upgrade logic whenever the URL used a secure scheme and the source expression's port looked "upgradable." The critical flaw was in isCurrentUpgradable: its first disjunct (m_port == defaultInsecurePort) checked only the port value with no scheme constraint. Any source expression with port 80 was considered upgradable — including https://host:80 where no scheme upgrade occurred. Because schemeMatches() returned a plain bool, the information about whether the match was exact (https↔https) or an insecure→secure upgrade (http→https) was lost before portMatches() ran.

Concrete bypass: source expression https://host:80 against URL https://host:443. schemeMatches() returns true (exact match). In portMatches(): isUpgradeSecure = true (port 443 == defaultSecurePort), isCurrentUpgradable = true (m_port 80 == defaultInsecurePort — first disjunct, no scheme check). Result: match, despite port 80 ≠ 443 and no scheme upgrade having occurred.

🔒

Detailed trace through the port-matching logic reveals which specific conditions enabled the bypass and how the fix's information-flow restructuring eliminates it

Subscribe to read more

🔒

Multiple audit patterns identified for similar information-loss bugs in browser policy matching code, with concrete search targets

Subscribe to read more