Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/frontend/src/lib/generated/internet_identity_idl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
8 changes: 8 additions & 0 deletions src/frontend/src/lib/generated/internet_identity_types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/lib/utils/iiConnection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/routes/vc-flow/index/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
6 changes: 6 additions & 0 deletions src/internet_identity/internet_identity.did
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/internet_identity/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -828,6 +829,11 @@ fn apply_install_arg(maybe_arg: Option<InternetIdentityInit>) {
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
Expand Down
87 changes: 79 additions & 8 deletions src/internet_identity/src/openid/sso.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,20 +224,45 @@ pub fn allowed_discovery_domains() -> Vec<String> {
}
}

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)
}
Comment thread
aterga marked this conversation as resolved.
Comment on lines +243 to +254

/// 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)
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -273,8 +298,9 @@ struct DiscoveryDocument {
#[cfg(not(test))]
async fn discovery_fill(domain: String) -> Result<DiscoveredConfig, String> {
// 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?;
Expand Down Expand Up @@ -455,12 +481,18 @@ fn host_with_port(url: &url::Url) -> Option<String> {
})
}

/// 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"
Expand Down Expand Up @@ -506,13 +538,15 @@ mod tests {

thread_local! {
pub(super) static TEST_ALLOWED: RefCell<Vec<String>> = const { RefCell::new(vec![]) };
pub(super) static TEST_ALLOW_ANY: RefCell<bool> = const { RefCell::new(false) };
pub(super) static TEST_DISCOVERY: RefCell<HashMap<String, DiscoveredConfig>> = RefCell::new(HashMap::new());
pub(super) static TEST_JWKS: RefCell<HashMap<String, Vec<Jwk>>> = RefCell::new(HashMap::new());
}

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());
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions src/internet_identity/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>>,
// 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<bool>,
// SSO provider configs managed via add_discoverable_oidc_config update call.
pub oidc_configs: Option<Vec<DiscoverableOidcConfig>>,
// Configuration for Web Analytics tool
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub struct StorablePersistentState {
openid_configs: Option<Vec<OpenIdConfig>>,
oidc_configs: Option<Vec<DiscoverableOidcConfig>>,
sso_discoverable_domains: Option<Vec<String>>,
sso_allow_any_domain: Option<bool>,
analytics_config: Option<AnalyticsConfig>,
enable_dapps_explorer: Option<bool>,
is_production: Option<bool>,
Expand Down Expand Up @@ -89,6 +90,7 @@ impl From<PersistentState> 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,
Expand Down Expand Up @@ -116,6 +118,7 @@ impl From<StorablePersistentState> 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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions src/internet_identity/tests/integration/config/sso_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>>,
/// 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<bool>,
/// 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
Expand Down
Loading