diff --git a/apps/backend/src/oauth/providers/base.tsx b/apps/backend/src/oauth/providers/base.tsx index efd31c0cf..a55b3ac14 100644 --- a/apps/backend/src/oauth/providers/base.tsx +++ b/apps/backend/src/oauth/providers/base.tsx @@ -4,6 +4,7 @@ import { wait } from "@hexclave/shared/dist/utils/promises"; import { Result } from "@hexclave/shared/dist/utils/results"; import { mergeScopeStrings } from "@hexclave/shared/dist/utils/strings"; import { CallbackParamsType, Client, Issuer, TokenSet as OIDCTokenSet, custom, generators } from "openid-client"; +import { assertSafeOAuthUrl, safeOAuthDnsLookup } from "../ssrf-protection"; import { OAuthUserInfo } from "../utils"; const OAUTH_USERINFO_TOTAL_ATTEMPTS = 3; @@ -32,6 +33,7 @@ const RETRYABLE_OAUTH_PROVIDER_ERROR_CODES = new Set([ // requests a little more room while still bounding backend request latency. custom.setHttpOptionsDefaults({ timeout: OAUTH_HTTP_TIMEOUT_MS, + lookup: safeOAuthDnsLookup, }); export type TokenSet = { @@ -305,6 +307,26 @@ function processTokenSet(providerName: string, tokenSet: OIDCTokenSet, defaultAc }; } +async function assertSafeDiscoveredIssuerMetadata(issuer: { + metadata: { + authorization_endpoint?: string, + token_endpoint?: string, + userinfo_endpoint?: string, + jwks_uri?: string, + }, +}) { + for (const url of [ + issuer.metadata.authorization_endpoint, + issuer.metadata.token_endpoint, + issuer.metadata.userinfo_endpoint, + issuer.metadata.jwks_uri, + ]) { + if (url !== undefined) { + await assertSafeOAuthUrl(url); + } + } +} + export abstract class OAuthBaseProvider { constructor( public readonly oauthClient: Client, @@ -354,13 +376,20 @@ export abstract class OAuthBaseProvider { } ) ) { - const issuer = "discoverFromUrl" in options ? await Issuer.discover(options.discoverFromUrl) : new Issuer({ - issuer: options.issuer, - authorization_endpoint: options.authorizationEndpoint, - token_endpoint: options.tokenEndpoint, - userinfo_endpoint: options.userinfoEndpoint, - jwks_uri: options.openid ? options.jwksUri : undefined, - }); + const issuer = "discoverFromUrl" in options + ? await (async () => { + await assertSafeOAuthUrl(options.discoverFromUrl); + const discoveredIssuer = await Issuer.discover(options.discoverFromUrl); + await assertSafeDiscoveredIssuerMetadata(discoveredIssuer); + return discoveredIssuer; + })() + : new Issuer({ + issuer: options.issuer, + authorization_endpoint: options.authorizationEndpoint, + token_endpoint: options.tokenEndpoint, + userinfo_endpoint: options.userinfoEndpoint, + jwks_uri: options.openid ? options.jwksUri : undefined, + }); const oauthClient = new issuer.Client({ client_id: options.clientId, client_secret: options.clientSecret, diff --git a/apps/backend/src/oauth/providers/twitch.tsx b/apps/backend/src/oauth/providers/twitch.tsx index bf6849193..04a888a40 100644 --- a/apps/backend/src/oauth/providers/twitch.tsx +++ b/apps/backend/src/oauth/providers/twitch.tsx @@ -41,6 +41,9 @@ export class TwitchProvider extends OAuthBaseProvider { displayName: userInfo.display_name, email: userInfo.email, profileImageUrl: userInfo.profile_image_url, + // Twitch documents this Helix field as "the user's verified email address" + // when the token has `user:read:email`. + // https://dev.twitch.tv/docs/api/reference/#get-users emailVerified: true, }); } diff --git a/apps/backend/src/oauth/ssrf-protection.test.ts b/apps/backend/src/oauth/ssrf-protection.test.ts new file mode 100644 index 000000000..b429fb8a8 --- /dev/null +++ b/apps/backend/src/oauth/ssrf-protection.test.ts @@ -0,0 +1,52 @@ +import { StatusError } from "@hexclave/shared/dist/utils/errors"; +import { describe, expect, it } from "vitest"; +import { assertSafeOAuthResolvedAddress, assertSafeOAuthUrlWithoutDns, isBlockedOAuthIpAddress } from "./ssrf-protection"; + +describe("isBlockedOAuthIpAddress", () => { + it("blocks AWS metadata, loopback, and private IPv4 ranges", () => { + expect(isBlockedOAuthIpAddress("169.254.169.254")).toBe(true); + expect(isBlockedOAuthIpAddress("127.0.0.1")).toBe(true); + expect(isBlockedOAuthIpAddress("10.0.0.8")).toBe(true); + expect(isBlockedOAuthIpAddress("172.16.0.1")).toBe(true); + expect(isBlockedOAuthIpAddress("192.168.1.1")).toBe(true); + }); + + it("blocks local and private IPv6 ranges", () => { + expect(isBlockedOAuthIpAddress("::1")).toBe(true); + expect(isBlockedOAuthIpAddress("[::1]")).toBe(true); + expect(isBlockedOAuthIpAddress("fe80::1")).toBe(true); + expect(isBlockedOAuthIpAddress("fc00::1")).toBe(true); + }); + + it("blocks IPv4-mapped IPv6 internal addresses", () => { + expect(isBlockedOAuthIpAddress("::ffff:127.0.0.1")).toBe(true); + expect(isBlockedOAuthIpAddress("::ffff:169.254.169.254")).toBe(true); + }); + + it("allows public IP addresses", () => { + expect(isBlockedOAuthIpAddress("8.8.8.8")).toBe(false); + expect(isBlockedOAuthIpAddress("2001:4860:4860::8888")).toBe(false); + }); +}); + +describe("assertSafeOAuthUrlWithoutDns", () => { + it("requires HTTPS", () => { + expect(() => assertSafeOAuthUrlWithoutDns("http://accounts.example.com")).toThrow(StatusError); + }); + + it("blocks IP-literal internal hosts", () => { + expect(() => assertSafeOAuthUrlWithoutDns("https://169.254.169.254/latest/meta-data/")).toThrow(StatusError); + expect(() => assertSafeOAuthUrlWithoutDns("https://[::1]/.well-known/openid-configuration")).toThrow(StatusError); + }); + + it("allows public HTTPS URLs before DNS resolution", () => { + expect(assertSafeOAuthUrlWithoutDns("https://accounts.google.com").hostname).toBe("accounts.google.com"); + }); +}); + +describe("assertSafeOAuthResolvedAddress", () => { + it("rejects DNS results that resolve to internal addresses", () => { + expect(() => assertSafeOAuthResolvedAddress("192.168.0.10")).toThrow(StatusError); + }); +}); + diff --git a/apps/backend/src/oauth/ssrf-protection.ts b/apps/backend/src/oauth/ssrf-protection.ts new file mode 100644 index 000000000..4e601abe3 --- /dev/null +++ b/apps/backend/src/oauth/ssrf-protection.ts @@ -0,0 +1,154 @@ +import dns from "node:dns"; +import net from "node:net"; +import { StatusError } from "@hexclave/shared/dist/utils/errors"; +import { getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; + +const OAUTH_SSRF_PROTECTION_ERROR = "OAuth provider URLs must use HTTPS and resolve only to public internet addresses."; + +const blockedAddressRanges = new net.BlockList(); +for (const [address, prefix, type] of [ + ["0.0.0.0", 8, "ipv4"], + ["10.0.0.0", 8, "ipv4"], + ["100.64.0.0", 10, "ipv4"], + ["127.0.0.0", 8, "ipv4"], + ["169.254.0.0", 16, "ipv4"], + ["172.16.0.0", 12, "ipv4"], + ["192.0.0.0", 24, "ipv4"], + ["192.0.2.0", 24, "ipv4"], + ["192.168.0.0", 16, "ipv4"], + ["198.18.0.0", 15, "ipv4"], + ["198.51.100.0", 24, "ipv4"], + ["203.0.113.0", 24, "ipv4"], + ["224.0.0.0", 4, "ipv4"], + ["240.0.0.0", 4, "ipv4"], + ["::", 128, "ipv6"], + ["::1", 128, "ipv6"], + ["64:ff9b::", 96, "ipv6"], + ["100::", 64, "ipv6"], + ["2001::", 23, "ipv6"], + ["2001:db8::", 32, "ipv6"], + ["fc00::", 7, "ipv6"], + ["fe80::", 10, "ipv6"], + ["ff00::", 8, "ipv6"], +] as const) { + blockedAddressRanges.addSubnet(address, prefix, type); +} + +function shouldEnforceOAuthSsrfProtection(): boolean { + return !["development", "test"].includes(getNodeEnvironment()); +} + +function hostnameWithoutIpv6Brackets(hostname: string): string { + if (hostname.startsWith("[") && hostname.endsWith("]")) { + return hostname.slice(1, -1); + } + return hostname; +} + +function getIpv4MappedAddress(address: string): string | null { + const prefix = "::ffff:"; + if (!address.toLowerCase().startsWith(prefix)) { + return null; + } + + const mappedAddress = address.slice(prefix.length); + return net.isIP(mappedAddress) === 4 ? mappedAddress : null; +} + +export function isBlockedOAuthIpAddress(address: string): boolean { + const normalizedAddress = hostnameWithoutIpv6Brackets(address); + const ipVersion = net.isIP(normalizedAddress); + if (ipVersion === 4) { + return blockedAddressRanges.check(normalizedAddress, "ipv4"); + } + if (ipVersion === 6) { + const mappedAddress = getIpv4MappedAddress(normalizedAddress); + if (mappedAddress !== null) { + return blockedAddressRanges.check(mappedAddress, "ipv4"); + } + return blockedAddressRanges.check(normalizedAddress, "ipv6"); + } + return false; +} + +export function assertSafeOAuthUrlWithoutDns(urlString: string): URL { + let url; + try { + url = new URL(urlString); + } catch (error) { + throw new StatusError(StatusError.BadRequest, "OAuth provider URL is not a valid URL."); + } + + if (url.protocol !== "https:") { + throw new StatusError(StatusError.BadRequest, OAUTH_SSRF_PROTECTION_ERROR); + } + + if (isBlockedOAuthIpAddress(url.hostname)) { + throw new StatusError(StatusError.BadRequest, OAUTH_SSRF_PROTECTION_ERROR); + } + + return url; +} + +export async function assertSafeOAuthUrl(urlString: string): Promise { + if (!shouldEnforceOAuthSsrfProtection()) { + return; + } + + const url = assertSafeOAuthUrlWithoutDns(urlString); + const hostname = hostnameWithoutIpv6Brackets(url.hostname); + if (net.isIP(hostname) !== 0) { + return; + } + + const addresses = await dns.promises.lookup(hostname, { all: true, verbatim: true }); + for (const address of addresses) { + assertSafeOAuthResolvedAddress(address.address); + } +} + +export function assertSafeOAuthResolvedAddress(address: string): void { + if (isBlockedOAuthIpAddress(address)) { + throw new StatusError(StatusError.BadRequest, OAUTH_SSRF_PROTECTION_ERROR); + } +} + +type DnsLookupCallback = ( + error: NodeJS.ErrnoException | null, + address: string | dns.LookupAddress[], + family?: number, +) => void; + +export function safeOAuthDnsLookup(hostname: string, options: dns.LookupOptions, callback: DnsLookupCallback): void { + if (!shouldEnforceOAuthSsrfProtection()) { + dns.lookup(hostname, options, callback); + return; + } + + if (options.all) { + const lookupOptions: dns.LookupAllOptions = { ...options, all: true }; + dns.lookup(hostname, lookupOptions, (error, addresses) => { + if (error) { + callback(error, []); + return; + } + + for (const address of addresses) { + assertSafeOAuthResolvedAddress(address.address); + } + callback(null, addresses); + }); + return; + } + + const lookupOptions: dns.LookupOneOptions = { ...options, all: false }; + dns.lookup(hostname, lookupOptions, (error, address, family) => { + if (error) { + callback(error, "", 0); + return; + } + + assertSafeOAuthResolvedAddress(address); + callback(null, address, family); + }); +}