Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/trusted-oauth-token-regions.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ import {
isUnusableSuccessTokenResponse,
optionalScopesFromAuthorizationUrl,
shouldRefreshToken,
tokenEndpointForRefresh,
type OAuth2TokenResponse,
type OAuthEndpointUrlPolicy,
} from "./oauth-helpers";
Expand Down Expand Up @@ -2694,7 +2695,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
// Refresh against the region the code was redeemed at when one was
// recorded at connect time (multi-site providers like Datadog), else
// the oauth_client's configured token endpoint.
const tokenUrl = row.oauth_token_url ? String(row.oauth_token_url) : clientRow.tokenUrl;
const tokenUrl = tokenEndpointForRefresh(
clientRow.tokenUrl,
row.oauth_token_url == null ? null : String(row.oauth_token_url),
);
if (tokenUrl === undefined) {
return yield* reauth("Stored OAuth token endpoint is no longer trusted; reconnect.");
}

// Enterprise-managed authorization (the ID-JAG grant profile) issues NO
// refresh token by design — the identity assertion in `refresh_item_id`
Expand Down
72 changes: 46 additions & 26 deletions packages/core/sdk/src/oauth-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,17 +307,20 @@ export const optionalScopesFromAuthorizationUrl = (authorizationUrl: string): re
// statically advertises `app.datadoghq.com`. Redeeming the code at the
// advertised host then fails with `invalid_grant`.
//
// `rebindTokenEndpointHostToCallbackDomain` swaps ONLY the hostname of the
// configured token URL to the callback-supplied host, and ONLY when that host
// is a sibling subdomain of the configured one (same parent after stripping the
// leftmost DNS label, e.g. `app.datadoghq.com` and `us5.datadoghq.com` both
// reduce to `datadoghq.com`). The token request carries the client secret, the
// code, and the PKCE verifier, so an attacker-influenced `domain` must never be
// able to point it at an arbitrary origin. Anything that fails the sibling
// check, fails to parse, or isn't https falls back to the configured URL
// unchanged.
// Token requests carry client credentials and grants. Callback data can select
// only an explicitly supported Datadog region, never a generic DNS sibling.
// Keep this list limited to the commercial datadoghq.com region family that
// this integration supports; other providers use their configured endpoint.
// ---------------------------------------------------------------------------

const DATADOG_REGIONAL_TOKEN_HOSTS: ReadonlySet<string> = 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;
Expand All @@ -327,45 +330,62 @@ 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,
): string => {
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);
rebound.hostname = targetHost;
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,
Expand Down
Loading