diff --git a/.changeset/trusted-oauth-token-regions.md b/.changeset/trusted-oauth-token-regions.md new file mode 100644 index 0000000000..9f3abec23a --- /dev/null +++ b/.changeset/trusted-oauth-token-regions.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Restrict callback-selected OAuth token endpoints to supported Datadog regions and validate persisted regional endpoints before refreshing credentials. diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 0f338405cd..2a6b823a5f 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -212,6 +212,7 @@ import { isUnusableSuccessTokenResponse, optionalScopesFromAuthorizationUrl, shouldRefreshToken, + tokenEndpointForRefresh, type OAuth2TokenResponse, type OAuthEndpointUrlPolicy, } from "./oauth-helpers"; @@ -2694,7 +2695,13 @@ export const createExecutor = = new Set([ + "app.datadoghq.com", + "us3.datadoghq.com", + "us5.datadoghq.com", + "ap1.datadoghq.com", + "ap2.datadoghq.com", +]); + const hostnameFromCallbackDomain = (callbackDomain: string): string | undefined => { const trimmed = callbackDomain.trim(); if (trimmed.length === 0) return undefined; @@ -327,23 +330,17 @@ const hostnameFromCallbackDomain = (callbackDomain: string): string | undefined if (!URL.canParse(candidate)) return undefined; const url = new URL(candidate); // A legitimate regional host carries no port, credentials, or path. - if (url.port !== "" || url.username !== "" || url.password !== "") return undefined; + if (url.protocol !== "https:" || url.port !== "" || url.username !== "" || url.password !== "") { + return undefined; + } + if (url.search !== "" || url.hash !== "") return undefined; if (url.pathname !== "/" && url.pathname !== "") return undefined; return url.hostname.toLowerCase(); }; -/** Parent domain after stripping the leftmost DNS label, or `undefined` when - * the host has no sibling space (a single label, or a parent that is a bare - * TLD). `app.datadoghq.com` -> `datadoghq.com`; `foo.com` -> undefined. */ -const siblingParentDomainOf = (hostname: string): string | undefined => { - const labels = hostname.split("."); - if (labels.length < 3) return undefined; - const parent = labels.slice(1).join("."); - // Require the parent to itself be multi-label so a 2-label configured host - // can never rebind across an entire TLD (e.g. foo.com -> bar.com). - return parent.includes(".") ? parent : undefined; -}; - +/** Select a supported Datadog token host from callback data, preserving every + * other URL component. Untrusted or malformed callbacks leave the configured + * endpoint unchanged; DNS suffix similarity never grants trust. */ export const rebindTokenEndpointHostToCallbackDomain = ( configuredTokenUrl: string, callbackDomain: string | null | undefined, @@ -351,14 +348,21 @@ export const rebindTokenEndpointHostToCallbackDomain = ( if (!callbackDomain) return configuredTokenUrl; if (!URL.canParse(configuredTokenUrl)) return configuredTokenUrl; const configured = new URL(configuredTokenUrl); - if (configured.protocol !== "https:") return configuredTokenUrl; + if ( + configured.protocol !== "https:" || + configured.port !== "" || + configured.username !== "" || + configured.password !== "" + ) + return configuredTokenUrl; const targetHost = hostnameFromCallbackDomain(callbackDomain); if (!targetHost) return configuredTokenUrl; const configuredHost = configured.hostname.toLowerCase(); if (targetHost === configuredHost) return configuredTokenUrl; - const configuredParent = siblingParentDomainOf(configuredHost); - const targetParent = siblingParentDomainOf(targetHost); - if (!configuredParent || !targetParent || configuredParent !== targetParent) { + if ( + !DATADOG_REGIONAL_TOKEN_HOSTS.has(configuredHost) || + !DATADOG_REGIONAL_TOKEN_HOSTS.has(targetHost) + ) { return configuredTokenUrl; } const rebound = new URL(configuredTokenUrl); @@ -366,6 +370,22 @@ export const rebindTokenEndpointHostToCallbackDomain = ( return rebound.toString(); }; +/** Parse a persisted refresh endpoint against the current client configuration. + * Only the configured URL or an exact supported regional substitution is + * accepted. Invalid legacy overrides return undefined, requiring reconnect. */ +export const tokenEndpointForRefresh = ( + configuredTokenUrl: string, + persistedTokenUrl: string | null, +): string | undefined => { + if (persistedTokenUrl === null || persistedTokenUrl === configuredTokenUrl) { + return configuredTokenUrl; + } + if (!URL.canParse(persistedTokenUrl) || !URL.canParse(configuredTokenUrl)) return undefined; + const persisted = new URL(persistedTokenUrl); + const trusted = rebindTokenEndpointHostToCallbackDomain(configuredTokenUrl, persisted.origin); + return new URL(trusted).href === persisted.href ? trusted : undefined; +}; + // --------------------------------------------------------------------------- // Error mapping — `oauth4webapi`'s `process*Response` failure shapes are // either a WWW-Authenticate challenge or an RFC 6749 §5.2 error body,