Skip to content

Product documentation

SSRF & outbound HTTP

How every outbound call to a tenant- or admin-influenced URL is guarded: OutboundUrlGuard / ReactiveOutboundUrlGuard, the older UrlSafetyValidator, and the SSRF-safe JWKS fetch.

SSRF & outbound HTTP

The platform makes outbound HTTP calls to URLs it does not fully control: federation-member discovery and JWKS URLs, SCIM outbound base URLs, external token-exchange issuers, and customer-registered webhooks. Any such URL is a Server-Side Request Forgery (SSRF) vector — an attacker who controls it could aim a request at a cloud-metadata endpoint or an internal service. Every one of those calls is validated before dispatch.

Two guards live in core/lib/common; a security reviewer should know both exist and how they differ.

OutboundUrlGuard / ReactiveOutboundUrlGuard

com.devnow.core.common.security.OutboundUrlGuard is the canonical, comprehensive guard for outbound calls whose target URL is influenced by data the platform does not own. Its reactive sibling ReactiveOutboundUrlGuard runs the same validation on a blocking-friendly scheduler so a Reactor event loop is never starved by the DNS lookup.

validate(rawUrl) rejects a URL unless it passes all of:

  • Scheme is exactly http or httpsfile://, gopher://, dict://, etc. are always rejected. Plain http is rejected by default (requireHttps), with a per-host opt-in (allowHttpHosts) for trusted dev/test deployments.
  • No embedded userinfo (user:pass@host) — an RFC 3986-deprecated SSRF/defence-in-depth vector.
  • No forbidden metadata hostnamemetadata.google.internal, metadata, instance-data, and similar are rejected before DNS resolution.
  • No blocked address — the host is resolved via InetAddress.getAllByName and every returned A/AAAA record is inspected. A hostname resolving to one public and one private address is rejected (a defence against split-horizon DNS-rebinding during the validation window). Blocked ranges:
FamilyBlocked
IPv4 private10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
IPv4 loopback127.0.0.0/8
IPv4 link-local (incl. AWS/GCP/Azure metadata 169.254.169.254)169.254.0.0/16
IPv4 broadcast / multicast / any-local224.0.0.0/4, 255.255.255.255, 0.0.0.0
IPv4 carrier-grade NAT100.64.0.0/10
IPv6 loopback / ULA / link-local::1, fc00::/7, fe80::/10
IPv4-mapped IPv6::ffff:0:0/96 mapping to any of the above (unwrapped, then re-checked)

A rejected URL throws OutboundUrlBlockedException, which callers translate to the error appropriate to the call site (invalid_request for an admin endpoint, a fail-open empty result on a credential-verification path, and so on).

Operators may opt specific hosts back in — for a federation member that legitimately lives in an RFC 1918 range on-prem — via oauthy.security.outbound-url-guard (allowList, exact host or .suffix match). The same allow-list is shared with UrlSafetyValidator.

Redirect caveat (stated honestly)

OutboundUrlGuard validates the URL once, at submission time. If the HTTP client is configured to follow 3xx redirects, the redirect target is not re-validated by the guard — a redirect to a metadata IP would currently bypass it. Per-call redirect interception is a tracked follow-up; until it lands, callers whose target URL is tenant-controlled must configure the client to refuse redirects. A reviewer should treat "guard + no-follow-redirects" as the complete control, not the guard alone.

Source: core/lib/common/.../security/OutboundUrlGuard.kt (class KDoc and the redirect TODO).

UrlSafetyValidator (the older guard)

com.devnow.core.common.UrlSafetyValidator (config prefix oauthy.security.url-safety) is the platform's earlier SSRF validator (SSO-772). It performs the same category of check — reject any host that resolves to a private, loopback, link-local, site-local, multicast, IPv6-ULA, CGNAT, or cloud-metadata address, inspecting every resolved record — and rejects non-http(s) schemes and embedded userinfo.

It is still the guard on two hub surfaces:

  • Dynamic Client Registration webhooksWebhookEndpointUrlValidator validates a registered webhook endpoint URL.
  • Workload-identity / external token-exchange issuersTrustedExternalIssuerService validates an external trusted issuer's URL before its discovery/JWKS is fetched.

It additionally exposes resolveSafe(url), which returns the validated InetAddress set so a caller can connect directly to a validated IP (with the original Host header) and close the time-of-check/time-of-use window entirely.

How the two guards relate

OutboundUrlGuard is the newer, more complete guard: it adds IPv4-mapped-IPv6 unwrapping, an explicit broadcast check, HTTPS-by-default enforcement, and a first-class reactive variant, and it shares UrlSafetyValidator's allow-list semantics. Both are live in the codebase today; newer outbound call sites should use OutboundUrlGuard, and the webhook / external-issuer surfaces continue to use UrlSafetyValidator. A reviewer auditing an outbound call should confirm it routes through one of the two before dispatch — a raw WebClient.get().uri(url) on an admin/tenant-supplied URL is the bug class both guards exist to prevent.

Source: UrlSafetyValidator.kt; WebhookEndpointUrlValidator.kt; TrustedExternalIssuerService.kt.

URLs read out of a fetched document

Validating the URL you were given is only half the problem. A federation member's OIDC discovery document, and an OpenID Federation entity statement, both contain more URLs — and those come from a remote party. Guarding the fetch of the document while dispatching freely to the endpoints inside it would leave the SSRF wide open one hop further along. Two places apply the guard a second time, to the document's contents.

OIDC discovery documents. OidcDiscoveryClient.fetchDiscovery validates the well-known URL before the fetch, then re-validates every dispatchable URL claim in the response body before handing the document back — jwks_uri, authorization_endpoint, token_endpoint, pushed_authorization_request_endpoint, userinfo_endpoint, end_session_endpoint, revocation_endpoint, introspection_endpoint, registration_endpoint, device_authorization_endpoint, backchannel_authentication_endpoint (DISPATCHABLE_URL_CLAIMS). A single blocked claim fails the whole fetch; the caller never receives a partially-validated document. The set is an allow-list of dispatch targets, not "everything that looks like a URL" — purely informational claims (service_documentation, op_policy_uri) are never fetched, so screening them would reject legitimate providers without closing any path.

Validating centrally, inside the discovery client, is deliberate: it is the one choke point every consumer of a discovery document shares, so no future caller can reintroduce the gap. The highest-stakes consumer is the hub's PAR resolver, which POSTs to pushed_authorization_request_endpoint with the federation client's HTTP Basic credentials — an unvalidated endpoint there is credential exfiltration, not merely internal reach. The Okta runtime reads its endpoints from a product-api-cached copy of the document rather than fetching it directly, so ProductApiFederationClient applies the same claim set to that body, and re-checks at both dispatch sites.

OpenID Federation entity statements. The trust-chain walk is driven by authority_hints taken verbatim out of a fetched statement, and then fetches {hint}/.well-known/openid-federation and {hint}/federation_fetch?sub=… from it — so every hop past the leaf is remote-controlled content. TrustChainResolver screens each hop before dispatch. A blocked hop is treated exactly like an unreachable one: the walk skips it and tries the next hint, and a chain with no usable hint fails. Refusing a bad address never means refusing to federate.

Enforcement. scripts/check-outbound-url-guard.sh (SSO-2095) scans production Kotlin for outbound dispatch to a dynamically-sourced URL in a file that references no SSRF primitive, and fails the build on a hit. It is a source-text scan, so it cannot follow a value across a module boundary — a call site relying on validation performed elsewhere carries an ssrf-guard-not-required: marker naming that choke point.

Source: OidcDiscoveryClient.kt; TrustChainResolver.kt; OktaFederationProvider.kt; check-outbound-url-guard.sh. SSO-790 established the jwks_uri case; SSO-2117 generalised it to the full endpoint set and to the federation walk.

The SSRF-safe JWKS fetch

Per-tenant token validation needs each tenant's JWKS, which is a fetch — and therefore a potential SSRF vector if the tenant's issuer could steer it. It cannot. As detailed on the tenant isolation page:

  1. The token's iss is checked against the trusted-issuer allowlist first; an untrusted iss is rejected with 401 and no fetch is attempted.
  2. For a trusted tenant, the JWKS is fetched from a fixed, operator-configured in-cluster hub Service URL, varying only the Host header (TenantIssuerJwksTransport). The outbound host is invariant — the token never influences it.

So even a trusted slug only ever produces a fetch to the one configured in-cluster URL, and a forged iss produces no fetch at all. This is a structural guard (fixed host) layered on top of the allowlist, not a URL-content check.

Source: TenantIssuerJwksTransport.kt; TrustedTenantIssuers.kt; ADR 2026-06-08-multi-issuer-customer-plane-token-validation.md.