diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index c9744aa3ed..8242348590 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -72,6 +72,7 @@ export const idlFactory = ({ IDL }) => { }); const InternetIdentityInit = IDL.Record({ 'doh_config' : IDL.Opt(IDL.Opt(DohConfig)), + 'sso_allow_any_domain' : IDL.Opt(IDL.Bool), 'sso_credential_migration' : IDL.Opt(IDL.Vec(SsoCredentialMigrationEntry)), 'is_production' : IDL.Opt(IDL.Bool), 'backend_canister_id' : IDL.Opt(IDL.Principal), @@ -1311,6 +1312,7 @@ export const init = ({ IDL }) => { }); const InternetIdentityInit = IDL.Record({ 'doh_config' : IDL.Opt(IDL.Opt(DohConfig)), + 'sso_allow_any_domain' : IDL.Opt(IDL.Bool), 'sso_credential_migration' : IDL.Opt(IDL.Vec(SsoCredentialMigrationEntry)), 'is_production' : IDL.Opt(IDL.Bool), 'backend_canister_id' : IDL.Opt(IDL.Principal), diff --git a/src/frontend/src/lib/generated/internet_identity_types.d.ts b/src/frontend/src/lib/generated/internet_identity_types.d.ts index 042d31bcd0..a4e17df5ea 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -942,6 +942,14 @@ export interface InternetIdentityInit { * `docs/ongoing/email-recovery.md` §7.6. Same set/clear pattern. */ 'doh_config' : [] | [[] | [DohConfig]], + /** + * Deploy flag opening the SSO discovery domain gate to any domain. When + * `true`, `sso_discoverable_domains` (and its defaults) no longer restrict + * which domains may be discovered as SSO providers. Does not relax the + * strict-`https` requirement: serving discovery over plain `http` still + * requires the host to be on the explicit `sso_discoverable_domains` list. + */ + 'sso_allow_any_domain' : [] | [boolean], /** * One-shot backfill of the `sso_domain` / `sso_name` fields on stored * OpenID credentials. When set, a batched timer-driven migration stamps diff --git a/src/frontend/src/lib/utils/iiConnection.test.ts b/src/frontend/src/lib/utils/iiConnection.test.ts index d947f5e3a0..67633e8a88 100644 --- a/src/frontend/src/lib/utils/iiConnection.test.ts +++ b/src/frontend/src/lib/utils/iiConnection.test.ts @@ -49,6 +49,7 @@ const DEFAULT_INIT: InternetIdentityInit = { captcha_config: [], openid_configs: [], sso_discoverable_domains: [], + sso_allow_any_domain: [], sso_credential_migration: [], register_rate_limit: [], related_origins: [ diff --git a/src/frontend/src/routes/vc-flow/index/+page.svelte b/src/frontend/src/routes/vc-flow/index/+page.svelte index 454ac5f24e..8f002db825 100644 --- a/src/frontend/src/routes/vc-flow/index/+page.svelte +++ b/src/frontend/src/routes/vc-flow/index/+page.svelte @@ -19,6 +19,7 @@ related_origins: frontendCanisterConfig.related_origins, openid_configs: [], sso_discoverable_domains: [], + sso_allow_any_domain: [], sso_credential_migration: [], backend_origin: [frontendCanisterConfig.backend_origin], captcha_config: [], diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index ec4a562002..30366caaa4 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -298,6 +298,12 @@ type InternetIdentityInit = record { // to `dfinity.org` (production) or `beta.dfinity.org` (everything else), // keyed off `is_production`. sso_discoverable_domains : opt vec text; + // Deploy flag opening the SSO discovery domain gate to any domain. When + // `true`, `sso_discoverable_domains` (and its defaults) no longer restrict + // which domains may be discovered as SSO providers. Does not relax the + // strict-`https` requirement: serving discovery over plain `http` still + // requires the host to be on the explicit `sso_discoverable_domains` list. + sso_allow_any_domain : opt bool; // One-shot backfill of the `sso_domain` / `sso_name` fields on stored // OpenID credentials. When set, a batched timer-driven migration stamps // every stored credential whose (iss, aud) matches an entry and whose diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 2f78cf69ae..6602c2ca4e 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -702,6 +702,7 @@ fn config() -> InternetIdentityInit { new_flow_origins: persistent_state.new_flow_origins.clone(), openid_configs: persistent_state.openid_configs.clone(), sso_discoverable_domains: persistent_state.sso_discoverable_domains.clone(), + sso_allow_any_domain: persistent_state.sso_allow_any_domain, // One-shot upgrade arg driving the SSO credential backfill; not // persisted as config, so there is nothing to report back here. sso_credential_migration: None, @@ -828,6 +829,11 @@ fn apply_install_arg(maybe_arg: Option) { persistent_state.sso_discoverable_domains = Some(sso_discoverable_domains); }) } + if let Some(sso_allow_any_domain) = arg.sso_allow_any_domain { + state::persistent_state_mut(|persistent_state| { + persistent_state.sso_allow_any_domain = Some(sso_allow_any_domain); + }) + } if let Some(entries) = arg.sso_credential_migration { // One-shot arg, not persisted: the entries only need to live // until the batch migration kicked off in `initialize()` has diff --git a/src/internet_identity/src/openid/sso.rs b/src/internet_identity/src/openid/sso.rs index c7ecd47cf3..3539feefd6 100644 --- a/src/internet_identity/src/openid/sso.rs +++ b/src/internet_identity/src/openid/sso.rs @@ -224,20 +224,45 @@ pub fn allowed_discovery_domains() -> Vec { } } -pub fn is_allowed_discovery_domain(domain: &str) -> bool { +/// Deploy flag: when set, the SSO discovery domain gate accepts *any* domain +/// (see `InternetIdentityInit::sso_allow_any_domain`). Deliberately does not +/// feed the `https`-relaxation gate ([`is_allowlisted_host`]), which always +/// consults the explicit allowlist so opening the domain gate never lets an +/// arbitrary host serve discovery over plain HTTP. +fn sso_allow_any_domain() -> bool { + #[cfg(not(test))] + { + state::persistent_state(|ps| ps.sso_allow_any_domain).unwrap_or(false) + } + #[cfg(test)] + { + tests::TEST_ALLOW_ANY.with_borrow(|b| *b) + } +} + +/// True if `domain` is on the configured/default SSO allowlist +/// (case-insensitive). The explicit list only — independent of the +/// `sso_allow_any_domain` deploy flag. +fn is_explicitly_allowlisted(domain: &str) -> bool { allowed_discovery_domains() .iter() .any(|allowed| allowed.eq_ignore_ascii_case(domain)) } +pub fn is_allowed_discovery_domain(domain: &str) -> bool { + sso_allow_any_domain() || is_explicitly_allowlisted(domain) +} + /// True if `host` (the `host:port` portion of a URL) matches an allowlist /// entry. Used as the gate for relaxing the `https://` requirement: any domain /// explicitly blessed by an II admin MAY publish its discovery endpoints over /// plain HTTP, which is what makes e2e tests against `http://localhost:11107` /// work without weakening prod's strict-HTTPS posture for unblessed hosts. +/// Consults the explicit allowlist only — the `sso_allow_any_domain` deploy +/// flag opens the domain gate but never relaxes the `https` requirement. #[cfg(not(test))] fn is_allowlisted_host(host: &str) -> bool { - is_allowed_discovery_domain(host) + is_explicitly_allowlisted(host) } // --------------------------------------------------------------------------- @@ -273,8 +298,9 @@ struct DiscoveryDocument { #[cfg(not(test))] async fn discovery_fill(domain: String) -> Result { // Hop 1: fetch /.well-known/ii-openid-configuration. Default to https; an - // allowlisted loopback host (the e2e provider, which can't serve TLS) may - // use http. The allowlist is the trust gate. + // explicitly allowlisted loopback host (the e2e provider, which can't serve + // TLS) may use http. The explicit allowlist is the trust gate — the + // `sso_allow_any_domain` flag opens the domain gate but never picks http. let hop1_scheme = scheme_for_allowlisted_host(&domain); let hop1_url = format!("{hop1_scheme}://{domain}/.well-known/ii-openid-configuration"); let ii_config = fetch_ii_openid_configuration(hop1_url).await?; @@ -455,12 +481,18 @@ fn host_with_port(url: &url::Url) -> Option { }) } -/// Scheme for the hop-1 URL of an allowlisted domain: loopback (the e2e test -/// provider) gets `http`, everything else `https`. -#[cfg(not(test))] +/// Scheme for the hop-1 URL. A loopback host (the e2e test provider, which +/// can't serve TLS) gets `http`, but *only* when it's explicitly allowlisted; +/// every other host gets `https`. Crucially, a loopback host that is reachable +/// only because the `sso_allow_any_domain` flag opened the domain gate is *not* +/// explicitly allowlisted, so it still gets `https`. This is what keeps the +/// flag from becoming a plain-HTTP SSRF footgun: opening the domain gate must +/// never let an un-allowlisted caller trigger an `http://` outcall to +/// `localhost`/`127.0.0.1`. Consults the same explicit-allowlist gate as the +/// hop-2 `https`-relaxation check ([`is_allowlisted_host`]). fn scheme_for_allowlisted_host(host: &str) -> &'static str { let bare = host.split(':').next().unwrap_or(host).to_ascii_lowercase(); - if matches!(bare.as_str(), "localhost" | "127.0.0.1") { + if matches!(bare.as_str(), "localhost" | "127.0.0.1") && is_explicitly_allowlisted(host) { "http" } else { "https" @@ -506,6 +538,7 @@ mod tests { thread_local! { pub(super) static TEST_ALLOWED: RefCell> = const { RefCell::new(vec![]) }; + pub(super) static TEST_ALLOW_ANY: RefCell = const { RefCell::new(false) }; pub(super) static TEST_DISCOVERY: RefCell> = RefCell::new(HashMap::new()); pub(super) static TEST_JWKS: RefCell>> = RefCell::new(HashMap::new()); } @@ -513,6 +546,7 @@ mod tests { fn reset() { set_test_now(1_700_000_000); TEST_ALLOWED.with_borrow_mut(|d| *d = vec!["example.org".to_string()]); + TEST_ALLOW_ANY.with_borrow_mut(|b| *b = false); TEST_DISCOVERY.with_borrow_mut(|m| m.clear()); TEST_JWKS.with_borrow_mut(|m| m.clear()); DISCOVERY_CACHE.with(|c| *c.borrow_mut() = new_discovery_cache()); @@ -548,6 +582,43 @@ mod tests { .is_err()); } + #[test] + fn allow_any_domain_opens_the_gate() { + reset(); + // Off by default: a domain off the explicit allowlist is rejected. + assert!(!is_allowed_discovery_domain("not-allowed.com")); + // Flag on: every domain passes the discovery gate. + TEST_ALLOW_ANY.with_borrow_mut(|b| *b = true); + assert!(is_allowed_discovery_domain("not-allowed.com")); + assert!(is_allowed_discovery_domain("example.org")); + // The explicit allowlist is unchanged — the `https`-relaxation gate + // still consults it, so the flag does not bless arbitrary http hosts. + assert!(is_explicitly_allowlisted("example.org")); + assert!(!is_explicitly_allowlisted("not-allowed.com")); + } + + #[test] + fn allow_any_domain_does_not_relax_https_for_loopback() { + reset(); + // e2e setup: the loopback provider is explicitly allowlisted, so hop-1 + // is allowed to use plain http (it can't serve TLS). + TEST_ALLOWED.with_borrow_mut(|d| *d = vec!["localhost:11107".to_string()]); + assert_eq!(scheme_for_allowlisted_host("localhost:11107"), "http"); + + // Flag on opens the *domain* gate for everything, loopback included... + TEST_ALLOW_ANY.with_borrow_mut(|b| *b = true); + assert!(is_allowed_discovery_domain("localhost")); + assert!(is_allowed_discovery_domain("127.0.0.1:8080")); + // ...but a loopback host reachable only via the flag (not on the + // explicit allowlist) still gets https: the flag must never trigger a + // plain-http outcall to localhost/127.0.0.1. + assert_eq!(scheme_for_allowlisted_host("localhost"), "https"); + assert_eq!(scheme_for_allowlisted_host("localhost:9999"), "https"); + assert_eq!(scheme_for_allowlisted_host("127.0.0.1:8080"), "https"); + // Non-loopback hosts are always https regardless. + assert_eq!(scheme_for_allowlisted_host("evil.example.com"), "https"); + } + #[test] fn prefetch_then_peek_resolves() { reset(); diff --git a/src/internet_identity/src/state.rs b/src/internet_identity/src/state.rs index 269f7cea42..05f31317be 100644 --- a/src/internet_identity/src/state.rs +++ b/src/internet_identity/src/state.rs @@ -124,6 +124,11 @@ pub struct PersistentState { // falls back to the built-in `is_production`-keyed defaults; `Some(vec)` // replaces them entirely. pub sso_discoverable_domains: Option>, + // Deploy flag opening the SSO discovery domain gate to any domain. `None`/ + // `Some(false)` keep `sso_discoverable_domains` (and its defaults) in force; + // `Some(true)` accepts every domain. Does not relax the strict-`https` + // requirement — see `openid::sso::is_allowed_discovery_domain`. + pub sso_allow_any_domain: Option, // SSO provider configs managed via add_discoverable_oidc_config update call. pub oidc_configs: Option>, // Configuration for Web Analytics tool @@ -168,6 +173,7 @@ impl Default for PersistentState { new_flow_origins: None, openid_configs: None, sso_discoverable_domains: None, + sso_allow_any_domain: None, oidc_configs: None, analytics_config: None, event_stats_24h_start: None, diff --git a/src/internet_identity/src/storage/storable/storable_persistent_state.rs b/src/internet_identity/src/storage/storable/storable_persistent_state.rs index 3a50c3878d..c0977f503e 100644 --- a/src/internet_identity/src/storage/storable/storable_persistent_state.rs +++ b/src/internet_identity/src/storage/storable/storable_persistent_state.rs @@ -39,6 +39,7 @@ pub struct StorablePersistentState { openid_configs: Option>, oidc_configs: Option>, sso_discoverable_domains: Option>, + sso_allow_any_domain: Option, analytics_config: Option, enable_dapps_explorer: Option, is_production: Option, @@ -89,6 +90,7 @@ impl From for StorablePersistentState { openid_configs: s.openid_configs, oidc_configs: s.oidc_configs, sso_discoverable_domains: s.sso_discoverable_domains, + sso_allow_any_domain: s.sso_allow_any_domain, analytics_config: s.analytics_config, enable_dapps_explorer: s.enable_dapps_explorer, is_production: s.is_production, @@ -116,6 +118,7 @@ impl From for PersistentState { openid_configs: s.openid_configs, oidc_configs: s.oidc_configs, sso_discoverable_domains: s.sso_discoverable_domains, + sso_allow_any_domain: s.sso_allow_any_domain, analytics_config: s.analytics_config, event_stats_24h_start: s.event_stats_24h_start, enable_dapps_explorer: s.enable_dapps_explorer, @@ -173,6 +176,7 @@ mod tests { openid_configs: None, oidc_configs: None, sso_discoverable_domains: None, + sso_allow_any_domain: None, analytics_config: None, enable_dapps_explorer: None, is_production: None, @@ -204,6 +208,7 @@ mod tests { openid_configs: None, oidc_configs: None, sso_discoverable_domains: None, + sso_allow_any_domain: None, event_stats_24h_start: None, analytics_config: None, enable_dapps_explorer: None, diff --git a/src/internet_identity/tests/integration/config/sso_discovery.rs b/src/internet_identity/tests/integration/config/sso_discovery.rs index 701bbeec13..6f1f0efe88 100644 --- a/src/internet_identity/tests/integration/config/sso_discovery.rs +++ b/src/internet_identity/tests/integration/config/sso_discovery.rs @@ -61,3 +61,33 @@ fn sso_discovery_honours_explicit_allowlist() { SsoDiscoveryState::NotAllowed ); } + +/// The `sso_allow_any_domain` deploy flag opens the gate to every domain: a +/// domain off the allowlist reads `Pending` instead of `NotAllowed`. +#[test] +fn sso_allow_any_domain_opens_the_gate() { + let env = env(); + let arg = InternetIdentityInit { + sso_discoverable_domains: Some(vec!["example.com".to_string()]), + sso_allow_any_domain: Some(true), + ..Default::default() + }; + let canister_id = install_ii_canister_with_arg_and_cycles( + &env, + II_WASM.clone(), + Some(arg), + 10_000_000_000_000, + ); + + // The explicit entry is still allowed. + assert_eq!( + api::get_sso_discovery(&env, canister_id, "example.com").unwrap(), + SsoDiscoveryState::Pending + ); + // A domain off the allowlist is now accepted rather than `NotAllowed`. + assert_eq!( + api::get_sso_discovery(&env, canister_id, "evil.example.com").unwrap(), + SsoDiscoveryState::Pending + ); + api::discover_sso(&env, canister_id, "evil.example.com").unwrap(); +} diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 802d7a314d..ac7674d927 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -286,6 +286,14 @@ pub struct InternetIdentityInit { /// `dfinity.org` (production) or `beta.dfinity.org` (everything else) /// keyed off `is_production`. pub sso_discoverable_domains: Option>, + /// Deploy flag that opens the SSO discovery domain gate to *any* domain. + /// When `Some(true)`, `sso_discoverable_domains` (and its built-in + /// `is_production` defaults) no longer restrict which domains may be + /// discovered as SSO providers — every domain is accepted. `None` / + /// `Some(false)` leave the allowlist in force. The strict-`https` posture + /// is unaffected: a domain must still be on the explicit + /// `sso_discoverable_domains` list to serve discovery over plain `http`. + pub sso_allow_any_domain: Option, /// One-shot backfill of the `sso_domain` / `sso_name` fields on stored /// `OpenIdCredential`s (see `docs/ongoing/openid-sso-prod-readiness.md` /// §8.6). When `Some`, a batched timer-driven migration stamps every