Skip to content

Commit 6c19897

Browse files
atergaclaude
andauthored
fix(be): keep loopback http gated by explicit allowlist under sso_allow_any_domain (#4048)
Addresses the review comment on #4045 (#4045 (comment)). ## Problem Hop-1 of SSO discovery chooses its scheme via `scheme_for_allowlisted_host`, which keyed **only** on whether the host was loopback (`localhost` / `127.0.0.1`) — it never consulted the allowlist. That was safe only under the prior invariant that *every* domain reaching discovery was already explicitly allowlisted. `sso_allow_any_domain` breaks that invariant: with the flag on, `is_allowed_discovery_domain` accepts **any** domain, so a non-allowlisted `localhost` / `127.0.0.1` now reaches hop-1 and gets a plain-`http://` outcall. That is an SSRF/footgun for staging deployments and contradicts the flag's stated "strict-`https` posture untouched" goal. ## Fix Gate the loopback `http` downgrade on **explicit** allowlisting (the same gate hop-2 already uses), so the flag opens the *domain* gate but never relaxes `https`: ```rust if matches!(bare.as_str(), "localhost" | "127.0.0.1") && is_explicitly_allowlisted(host) { "http" } else { "https" } ``` - A loopback host reachable *only* via the flag (not on the explicit allowlist) now gets `https` — no plain-HTTP outcall to loopback. - The e2e provider `localhost:11107` stays on the explicit `sso_discoverable_domains` allowlist, so its `http` path is unaffected (no e2e regression). - Adds regression unit test `allow_any_domain_does_not_relax_https_for_loopback`. > Note: this is stacked on `feat/sso-allow-any-domain`; it can also just be cherry-picked as a single commit onto that branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LDnAektYFmpUWDHPLHypkQ --- _Generated by [Claude Code](https://claude.ai/code/session_01LDnAektYFmpUWDHPLHypkQ)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 26bdb1d commit 6c19897

1 file changed

Lines changed: 35 additions & 6 deletions

File tree

  • src/internet_identity/src/openid

src/internet_identity/src/openid/sso.rs

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -298,8 +298,9 @@ struct DiscoveryDocument {
298298
#[cfg(not(test))]
299299
async fn discovery_fill(domain: String) -> Result<DiscoveredConfig, String> {
300300
// Hop 1: fetch /.well-known/ii-openid-configuration. Default to https; an
301-
// allowlisted loopback host (the e2e provider, which can't serve TLS) may
302-
// use http. The allowlist is the trust gate.
301+
// explicitly allowlisted loopback host (the e2e provider, which can't serve
302+
// TLS) may use http. The explicit allowlist is the trust gate — the
303+
// `sso_allow_any_domain` flag opens the domain gate but never picks http.
303304
let hop1_scheme = scheme_for_allowlisted_host(&domain);
304305
let hop1_url = format!("{hop1_scheme}://{domain}/.well-known/ii-openid-configuration");
305306
let ii_config = fetch_ii_openid_configuration(hop1_url).await?;
@@ -480,12 +481,18 @@ fn host_with_port(url: &url::Url) -> Option<String> {
480481
})
481482
}
482483

483-
/// Scheme for the hop-1 URL of an allowlisted domain: loopback (the e2e test
484-
/// provider) gets `http`, everything else `https`.
485-
#[cfg(not(test))]
484+
/// Scheme for the hop-1 URL. A loopback host (the e2e test provider, which
485+
/// can't serve TLS) gets `http`, but *only* when it's explicitly allowlisted;
486+
/// every other host gets `https`. Crucially, a loopback host that is reachable
487+
/// only because the `sso_allow_any_domain` flag opened the domain gate is *not*
488+
/// explicitly allowlisted, so it still gets `https`. This is what keeps the
489+
/// flag from becoming a plain-HTTP SSRF footgun: opening the domain gate must
490+
/// never let an un-allowlisted caller trigger an `http://` outcall to
491+
/// `localhost`/`127.0.0.1`. Consults the same explicit-allowlist gate as the
492+
/// hop-2 `https`-relaxation check ([`is_allowlisted_host`]).
486493
fn scheme_for_allowlisted_host(host: &str) -> &'static str {
487494
let bare = host.split(':').next().unwrap_or(host).to_ascii_lowercase();
488-
if matches!(bare.as_str(), "localhost" | "127.0.0.1") {
495+
if matches!(bare.as_str(), "localhost" | "127.0.0.1") && is_explicitly_allowlisted(host) {
489496
"http"
490497
} else {
491498
"https"
@@ -590,6 +597,28 @@ mod tests {
590597
assert!(!is_explicitly_allowlisted("not-allowed.com"));
591598
}
592599

600+
#[test]
601+
fn allow_any_domain_does_not_relax_https_for_loopback() {
602+
reset();
603+
// e2e setup: the loopback provider is explicitly allowlisted, so hop-1
604+
// is allowed to use plain http (it can't serve TLS).
605+
TEST_ALLOWED.with_borrow_mut(|d| *d = vec!["localhost:11107".to_string()]);
606+
assert_eq!(scheme_for_allowlisted_host("localhost:11107"), "http");
607+
608+
// Flag on opens the *domain* gate for everything, loopback included...
609+
TEST_ALLOW_ANY.with_borrow_mut(|b| *b = true);
610+
assert!(is_allowed_discovery_domain("localhost"));
611+
assert!(is_allowed_discovery_domain("127.0.0.1:8080"));
612+
// ...but a loopback host reachable only via the flag (not on the
613+
// explicit allowlist) still gets https: the flag must never trigger a
614+
// plain-http outcall to localhost/127.0.0.1.
615+
assert_eq!(scheme_for_allowlisted_host("localhost"), "https");
616+
assert_eq!(scheme_for_allowlisted_host("localhost:9999"), "https");
617+
assert_eq!(scheme_for_allowlisted_host("127.0.0.1:8080"), "https");
618+
// Non-loopback hosts are always https regardless.
619+
assert_eq!(scheme_for_allowlisted_host("evil.example.com"), "https");
620+
}
621+
593622
#[test]
594623
fn prefetch_then_peek_resolves() {
595624
reset();

0 commit comments

Comments
 (0)