mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
OAuth improvements
This commit is contained in:
parent
c749cf2b62
commit
3e53da8fce
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
52
apps/backend/src/oauth/ssrf-protection.test.ts
Normal file
52
apps/backend/src/oauth/ssrf-protection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
154
apps/backend/src/oauth/ssrf-protection.ts
Normal file
154
apps/backend/src/oauth/ssrf-protection.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user