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
httporhttps—file://,gopher://,dict://, etc. are always rejected. Plainhttpis 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 hostname —
metadata.google.internal,metadata,instance-data, and similar are rejected before DNS resolution. - No blocked address — the host is resolved via
InetAddress.getAllByNameand 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:
| Family | Blocked |
|---|---|
| IPv4 private | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
| IPv4 loopback | 127.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-local | 224.0.0.0/4, 255.255.255.255, 0.0.0.0 |
| IPv4 carrier-grade NAT | 100.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 webhooks —
WebhookEndpointUrlValidatorvalidates a registered webhook endpoint URL. - Workload-identity / external token-exchange issuers —
TrustedExternalIssuerServicevalidates 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:
- The token's
issis checked against the trusted-issuer allowlist first; an untrustedissis rejected with401and no fetch is attempted. - For a trusted tenant, the JWKS is fetched from a fixed, operator-configured in-cluster hub
Service URL, varying only the
Hostheader (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.