OAuth improvements

This commit is contained in:
Konstantin Wohlwend 2026-06-25 14:40:13 -07:00
parent c749cf2b62
commit 3e53da8fce
4 changed files with 245 additions and 7 deletions

View File

@ -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,

View File

@ -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,
});
}

View File

@ -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);
});
});

View File

@ -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<void> {
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);
});
}