mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Cleaner SMTP connections
This commit is contained in:
parent
82517ee69d
commit
c5c853ccff
@ -2,6 +2,7 @@ import { globalPrismaClient } from "@/prisma-client";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { getClickhouseAdminClient } from "@/lib/clickhouse";
|
||||
import { getSafeExternalPostgresClientOptions } from "@/lib/ssrf-protection/external-db-sync";
|
||||
import type { CompleteConfig } from "@hexclave/shared/dist/config/schema";
|
||||
import {
|
||||
adaptSchema,
|
||||
@ -851,7 +852,32 @@ async function fetchExternalDatabaseStatus(
|
||||
};
|
||||
}
|
||||
|
||||
const client = new Client({ connectionString: dbConfig.connectionString });
|
||||
const clientOptionsResult = await Result.fromPromise(getSafeExternalPostgresClientOptions(dbConfig.connectionString));
|
||||
if (clientOptionsResult.status === "error") {
|
||||
return {
|
||||
id: dbId,
|
||||
type: dbConfig.type,
|
||||
connection,
|
||||
status: "error" as const,
|
||||
error: formatError(clientOptionsResult.error),
|
||||
metadata: [],
|
||||
users_table: {
|
||||
exists: false,
|
||||
total_rows: null,
|
||||
min_signed_up_at_millis: null,
|
||||
max_signed_up_at_millis: null,
|
||||
},
|
||||
mapping_status: mappingStatuses.map((mapping) => ({
|
||||
mapping_id: mapping.mapping_id,
|
||||
internal_max_sequence_id: mapping.internal_max_sequence_id,
|
||||
last_synced_sequence_id: null,
|
||||
updated_at_millis: null,
|
||||
backlog: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const client = new Client(clientOptionsResult.data);
|
||||
const connectResult = await Result.fromPromise(client.connect());
|
||||
if (connectResult.status === "error") {
|
||||
return {
|
||||
|
||||
@ -10,7 +10,7 @@ import { runAsynchronously, wait } from '@hexclave/shared/dist/utils/promises';
|
||||
import { Result } from '@hexclave/shared/dist/utils/results';
|
||||
import { traceSpan } from '@hexclave/shared/dist/utils/telemetry';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { checkSmtpEgressPolicy } from '@/private';
|
||||
import { checkSmtpEgressPolicy, shouldEnforceSmtpEgressPolicy } from '@/lib/ssrf-protection/smtp';
|
||||
|
||||
export function isSecureEmailPort(port: number | string) {
|
||||
// "secure" in most SMTP clients means implicit TLS from byte 1 (SMTPS)
|
||||
@ -81,25 +81,39 @@ async function _lowLevelSendEmailWithoutRetries(options: LowLevelSendEmailOption
|
||||
|
||||
return await traceSpan('sending email to ' + JSON.stringify(toArray), async () => {
|
||||
try {
|
||||
const smtpEgressPolicyResult = await checkSmtpEgressPolicy({
|
||||
host: options.emailConfig.host,
|
||||
port: options.emailConfig.port,
|
||||
});
|
||||
if (smtpEgressPolicyResult.status === "error") {
|
||||
console.warn("SMTP config rejected by the egress policy.", {
|
||||
violation: smtpEgressPolicyResult.violation,
|
||||
config: strippedEmailConfig,
|
||||
// Only tenant-provided ("standard") SMTP configs are attacker-controlled, so the egress policy
|
||||
// only applies to those. "shared" (operator/Hexclave server) and "managed" (Resend) are trusted
|
||||
// — enforcing the policy on them would needlessly break e.g. local dev (Inbucket on 127.0.0.1).
|
||||
let connectHost = options.emailConfig.host;
|
||||
let connectServername: string | undefined = undefined;
|
||||
if (options.emailConfig.type === 'standard' && shouldEnforceSmtpEgressPolicy()) {
|
||||
const smtpEgressPolicyResult = await checkSmtpEgressPolicy({
|
||||
host: options.emailConfig.host,
|
||||
port: options.emailConfig.port,
|
||||
});
|
||||
captureError("smtp-egress-policy-report-only", new HexclaveAssertionError("SMTP config would be rejected by the egress policy", {
|
||||
violation: smtpEgressPolicyResult.violation,
|
||||
config: strippedEmailConfig,
|
||||
}));
|
||||
if (smtpEgressPolicyResult.status === "error") {
|
||||
captureError("smtp-egress-policy-rejected", new HexclaveAssertionError("SMTP config was rejected by the egress policy", {
|
||||
violation: smtpEgressPolicyResult.violation,
|
||||
config: strippedEmailConfig,
|
||||
}));
|
||||
return Result.error({
|
||||
rawError: smtpEgressPolicyResult.violation,
|
||||
errorType: 'EGRESS_POLICY_REJECTED',
|
||||
canRetry: false,
|
||||
message: 'The email server host or port is not allowed. Please use a public SMTP server on a standard SMTP port.',
|
||||
} as const);
|
||||
}
|
||||
// Pin the connection to a validated address so nodemailer can't re-resolve the hostname to an
|
||||
// internal IP after our check (DNS rebinding); keep the hostname as the TLS SNI/cert name.
|
||||
connectHost = smtpEgressPolicyResult.connectHost;
|
||||
connectServername = smtpEgressPolicyResult.servername ?? undefined;
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: options.emailConfig.host,
|
||||
host: connectHost,
|
||||
port: options.emailConfig.port,
|
||||
secure: options.emailConfig.secure,
|
||||
...(connectServername != null ? { servername: connectServername } : {}),
|
||||
disableFileAccess: true,
|
||||
disableUrlAccess: true,
|
||||
connectionTimeout: 15000,
|
||||
|
||||
@ -3,6 +3,7 @@ import type { PrismaTransaction } from "@/lib/types";
|
||||
import { getPrismaClientForTenancy, PrismaClientWithReplica } from "@/prisma-client";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { getClickhouseAdminClient } from "@/lib/clickhouse";
|
||||
import { getSafeExternalPostgresClientOptions } from "@/lib/ssrf-protection/external-db-sync";
|
||||
import { DEFAULT_DB_SYNC_MAPPINGS } from "@hexclave/shared/dist/config/db-sync-mappings";
|
||||
import type { CompleteConfig } from "@hexclave/shared/dist/config/schema";
|
||||
import { captureError, HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
@ -1452,10 +1453,9 @@ async function syncDatabase(
|
||||
);
|
||||
}
|
||||
assertNonEmptyString(dbConfig.connectionString, `external DB ${dbId} connectionString`);
|
||||
const externalClientOptions = await getSafeExternalPostgresClientOptions(dbConfig.connectionString);
|
||||
|
||||
const externalClient = new Client({
|
||||
connectionString: dbConfig.connectionString,
|
||||
});
|
||||
const externalClient = new Client(externalClientOptions);
|
||||
|
||||
let needsResync = false;
|
||||
const syncResult = await Result.fromPromise((async () => {
|
||||
|
||||
172
apps/backend/src/lib/ssrf-protection/core.ts
Normal file
172
apps/backend/src/lib/ssrf-protection/core.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import dns from "node:dns";
|
||||
import net from "node:net";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export type DnsLookupCallback = (
|
||||
error: NodeJS.ErrnoException | null,
|
||||
address: string | dns.LookupAddress[],
|
||||
family?: number,
|
||||
) => void;
|
||||
|
||||
export function hostnameWithoutIpv6Brackets(hostname: string): string {
|
||||
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
||||
return hostname.slice(1, -1);
|
||||
}
|
||||
return hostname;
|
||||
}
|
||||
|
||||
function normalizeIpv6Address(address: string): string {
|
||||
const hostname = hostnameWithoutIpv6Brackets(address);
|
||||
try {
|
||||
return hostnameWithoutIpv6Brackets(new URL(`http://[${hostname}]/`).hostname).toLowerCase();
|
||||
} catch (error) {
|
||||
return hostname.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function getIpv4MappedAddress(address: string): string | null {
|
||||
const prefix = "::ffff:";
|
||||
const normalizedAddress = normalizeIpv6Address(address);
|
||||
if (!normalizedAddress.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mappedAddress = normalizedAddress.slice(prefix.length);
|
||||
if (net.isIP(mappedAddress) === 4) {
|
||||
return mappedAddress;
|
||||
}
|
||||
|
||||
const hexMatch = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(mappedAddress);
|
||||
if (!hexMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const high = Number.parseInt(hexMatch[1], 16);
|
||||
const low = Number.parseInt(hexMatch[2], 16);
|
||||
if (!Number.isInteger(high) || !Number.isInteger(low) || high > 0xffff || low > 0xffff) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
high >> 8,
|
||||
high & 0xff,
|
||||
low >> 8,
|
||||
low & 0xff,
|
||||
].join(".");
|
||||
}
|
||||
|
||||
export function isBlockedPrivateOrReservedIpAddress(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 assertPublicInternetResolvedAddress(address: string, error: Error): void {
|
||||
if (isBlockedPrivateOrReservedIpAddress(address)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertHostnameResolvesToPublicInternet(hostname: string, error: Error): Promise<void> {
|
||||
const addresses = await dns.promises.lookup(hostnameWithoutIpv6Brackets(hostname), { all: true, verbatim: true });
|
||||
for (const address of addresses) {
|
||||
assertPublicInternetResolvedAddress(address.address, error);
|
||||
}
|
||||
}
|
||||
|
||||
function getLookupValidationError(validate: () => void, fallbackMessage: string): NodeJS.ErrnoException | null {
|
||||
try {
|
||||
validate();
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
return new Error(fallbackMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export function publicInternetDnsLookup(
|
||||
hostname: string,
|
||||
options: dns.LookupOptions,
|
||||
callback: DnsLookupCallback,
|
||||
error: Error,
|
||||
): void {
|
||||
if (options.all) {
|
||||
const lookupOptions: dns.LookupAllOptions = { ...options, all: true };
|
||||
dns.lookup(hostname, lookupOptions, (lookupError, addresses) => {
|
||||
if (lookupError) {
|
||||
callback(lookupError, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = getLookupValidationError(() => {
|
||||
for (const address of addresses) {
|
||||
assertPublicInternetResolvedAddress(address.address, error);
|
||||
}
|
||||
}, "DNS lookup failed while validating resolved addresses.");
|
||||
if (validationError !== null) {
|
||||
callback(validationError, []);
|
||||
return;
|
||||
}
|
||||
callback(null, addresses);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const lookupOptions: dns.LookupOneOptions = { ...options, all: false };
|
||||
dns.lookup(hostname, lookupOptions, (lookupError, address, family) => {
|
||||
if (lookupError) {
|
||||
callback(lookupError, "", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = getLookupValidationError(
|
||||
() => assertPublicInternetResolvedAddress(address, error),
|
||||
"DNS lookup failed while validating resolved address.",
|
||||
);
|
||||
if (validationError !== null) {
|
||||
callback(validationError, "", 0);
|
||||
return;
|
||||
}
|
||||
callback(null, address, family);
|
||||
});
|
||||
}
|
||||
122
apps/backend/src/lib/ssrf-protection/external-db-sync.test.ts
Normal file
122
apps/backend/src/lib/ssrf-protection/external-db-sync.test.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { StatusError } from "@hexclave/shared/dist/utils/errors";
|
||||
import dnsPromises from "node:dns/promises";
|
||||
import type { LookupAddress } from "node:dns";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { assertSafeExternalPostgresConnectionString, getSafeExternalPostgresClientOptions } from "./external-db-sync";
|
||||
|
||||
async function withProductionExternalDbSsrfProtection<T>(callback: () => Promise<T>): Promise<T> {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
}
|
||||
|
||||
function mockDnsLookup(addresses: LookupAddress[]) {
|
||||
return vi.spyOn(dnsPromises, "lookup").mockResolvedValue(addresses as never);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("assertSafeExternalPostgresConnectionString", () => {
|
||||
it("rejects malformed or non-Postgres connection strings in all environments", async () => {
|
||||
await expect(assertSafeExternalPostgresConnectionString("not-a-url")).rejects.toThrow(StatusError);
|
||||
await expect(assertSafeExternalPostgresConnectionString("https://db.example.com/database")).rejects.toThrow(StatusError);
|
||||
});
|
||||
|
||||
it("blocks private and metadata IP literal targets in production", async () => {
|
||||
await withProductionExternalDbSsrfProtection(async () => {
|
||||
await expect(assertSafeExternalPostgresConnectionString("postgres://user:pass@127.0.0.1:5432/db")).rejects.toThrow(StatusError);
|
||||
await expect(assertSafeExternalPostgresConnectionString("postgres://user:pass@169.254.169.254:5432/db")).rejects.toThrow(StatusError);
|
||||
await expect(assertSafeExternalPostgresConnectionString("postgres://user:pass@[::1]:5432/db")).rejects.toThrow(StatusError);
|
||||
await expect(assertSafeExternalPostgresConnectionString("postgres://user:pass@[::ffff:7f00:1]:5432/db")).rejects.toThrow(StatusError);
|
||||
});
|
||||
});
|
||||
|
||||
it("allows private targets when the operator escape hatch is set", async () => {
|
||||
await withProductionExternalDbSsrfProtection(async () => {
|
||||
vi.stubEnv("HEXCLAVE_ALLOW_EXTERNAL_DB_SYNC_PRIVATE_HOSTS", "true");
|
||||
await expect(assertSafeExternalPostgresConnectionString("postgres://user:pass@127.0.0.1:5432/db")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSafeExternalPostgresClientOptions", () => {
|
||||
it("returns the raw connection string unchanged when protection is not enforced (dev/test)", async () => {
|
||||
const connectionString = "postgres://user:pass@10.0.0.1:5432/db";
|
||||
await expect(getSafeExternalPostgresClientOptions(connectionString)).resolves.toEqual({
|
||||
connectionString,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("connects to public IP literals directly without pinning (no rebinding window)", async () => {
|
||||
await withProductionExternalDbSsrfProtection(async () => {
|
||||
const connectionString = "postgres://user:pass@8.8.8.8:5432/db";
|
||||
await expect(getSafeExternalPostgresClientOptions(connectionString)).resolves.toEqual({
|
||||
connectionString,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("pins hostnames to a validated resolved address with the hostname preserved as SNI servername", async () => {
|
||||
await withProductionExternalDbSsrfProtection(async () => {
|
||||
mockDnsLookup([{ address: "93.184.216.34", family: 4 }]);
|
||||
const options = await getSafeExternalPostgresClientOptions(
|
||||
"postgres://user:pass@db.example.com:6432/mydb?sslmode=require",
|
||||
);
|
||||
expect(options).toMatchObject({
|
||||
host: "93.184.216.34",
|
||||
port: 6432,
|
||||
user: "user",
|
||||
password: "pass",
|
||||
database: "mydb",
|
||||
connectionTimeoutMillis: 10_000,
|
||||
});
|
||||
expect(options.ssl).toEqual({ servername: "db.example.com" });
|
||||
expect(options.connectionString).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves plaintext (no TLS) when the connection string has no ssl parameters", async () => {
|
||||
await withProductionExternalDbSsrfProtection(async () => {
|
||||
mockDnsLookup([{ address: "93.184.216.34", family: 4 }]);
|
||||
const options = await getSafeExternalPostgresClientOptions("postgres://user:pass@db.example.com/mydb");
|
||||
expect(options.host).toBe("93.184.216.34");
|
||||
expect(options.ssl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("maps sslmode=disable to no TLS and sslmode=no-verify to unverified TLS", async () => {
|
||||
await withProductionExternalDbSsrfProtection(async () => {
|
||||
mockDnsLookup([{ address: "93.184.216.34", family: 4 }]);
|
||||
const disabled = await getSafeExternalPostgresClientOptions("postgres://u:p@db.example.com/d?sslmode=disable");
|
||||
expect(disabled.ssl).toBe(false);
|
||||
|
||||
const noVerify = await getSafeExternalPostgresClientOptions("postgres://u:p@db.example.com/d?sslmode=no-verify");
|
||||
expect(noVerify.ssl).toEqual({ servername: "db.example.com", rejectUnauthorized: false });
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects hostnames that resolve to an internal address", async () => {
|
||||
await withProductionExternalDbSsrfProtection(async () => {
|
||||
mockDnsLookup([{ address: "10.0.0.5", family: 4 }]);
|
||||
await expect(
|
||||
getSafeExternalPostgresClientOptions("postgres://user:pass@sneaky.example.com/db"),
|
||||
).rejects.toThrow(StatusError);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects localhost hostnames in production", async () => {
|
||||
await withProductionExternalDbSsrfProtection(async () => {
|
||||
await expect(
|
||||
getSafeExternalPostgresClientOptions("postgres://user:pass@db.localhost/db"),
|
||||
).rejects.toThrow(StatusError);
|
||||
});
|
||||
});
|
||||
});
|
||||
145
apps/backend/src/lib/ssrf-protection/external-db-sync.ts
Normal file
145
apps/backend/src/lib/ssrf-protection/external-db-sync.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import {
|
||||
assertPublicInternetResolvedAddress,
|
||||
hostnameWithoutIpv6Brackets,
|
||||
isBlockedPrivateOrReservedIpAddress,
|
||||
} from "./core";
|
||||
import { getEnvVariable, getNodeEnvironment } from "@hexclave/shared/dist/utils/env";
|
||||
import { StatusError } from "@hexclave/shared/dist/utils/errors";
|
||||
import dns from "node:dns/promises";
|
||||
import net from "node:net";
|
||||
import type { ClientConfig } from "pg";
|
||||
|
||||
const EXTERNAL_DB_SYNC_SSRF_PROTECTION_ERROR =
|
||||
"External Postgres DB sync connection strings must use postgres:// or postgresql:// and resolve only to public internet addresses.";
|
||||
|
||||
const EXTERNAL_DB_CONNECT_TIMEOUT_MS = 10_000;
|
||||
|
||||
function shouldEnforceExternalDbSyncSsrfProtection(): boolean {
|
||||
if (["development", "test"].includes(getNodeEnvironment())) {
|
||||
return false;
|
||||
}
|
||||
return getEnvVariable("HEXCLAVE_ALLOW_EXTERNAL_DB_SYNC_PRIVATE_HOSTS", "false") !== "true";
|
||||
}
|
||||
|
||||
function externalDbSyncSsrfProtectionError() {
|
||||
return new StatusError(StatusError.BadRequest, EXTERNAL_DB_SYNC_SSRF_PROTECTION_ERROR);
|
||||
}
|
||||
|
||||
function parseExternalPostgresConnectionString(connectionString: string): URL {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(connectionString);
|
||||
} catch (error) {
|
||||
throw externalDbSyncSsrfProtectionError();
|
||||
}
|
||||
|
||||
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") {
|
||||
throw externalDbSyncSsrfProtectionError();
|
||||
}
|
||||
|
||||
if (!url.hostname) {
|
||||
throw externalDbSyncSsrfProtectionError();
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
function isLocalhostName(hostname: string): boolean {
|
||||
const normalizedHostname = hostnameWithoutIpv6Brackets(hostname).toLowerCase().replace(/\.$/, "");
|
||||
return normalizedHostname === "localhost" || normalizedHostname.endsWith(".localhost");
|
||||
}
|
||||
|
||||
function buildPinnedPostgresSslConfig(searchParams: URLSearchParams, servername: string): ClientConfig["ssl"] {
|
||||
const sslParam = searchParams.get("ssl");
|
||||
if (sslParam === "0" || sslParam === "false") {
|
||||
return false;
|
||||
}
|
||||
const sslMode = searchParams.get("sslmode");
|
||||
if (sslMode === "disable") {
|
||||
return false;
|
||||
}
|
||||
if (sslMode === "no-verify") {
|
||||
// Matches pg's handling of sslmode=no-verify: encrypt but skip certificate verification.
|
||||
return { servername, rejectUnauthorized: false };
|
||||
}
|
||||
if (sslParam != null || sslMode != null) {
|
||||
// TLS is enabled (ssl=true / sslmode=prefer|require|verify-ca|verify-full). Because we connect to
|
||||
// a pinned IP rather than the hostname, the SNI + certificate-identity name must be set to the
|
||||
// original hostname, otherwise verification would run against the IP and fail.
|
||||
return { servername };
|
||||
}
|
||||
// No SSL parameters at all: preserve pg's default (no TLS unless configured via PGSSLMODE).
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildPinnedPostgresClientOptions(url: URL, pinnedAddress: string, servername: string): ClientConfig {
|
||||
const options: ClientConfig = {
|
||||
host: pinnedAddress,
|
||||
connectionTimeoutMillis: EXTERNAL_DB_CONNECT_TIMEOUT_MS,
|
||||
ssl: buildPinnedPostgresSslConfig(url.searchParams, servername),
|
||||
};
|
||||
if (url.port) {
|
||||
options.port = Number.parseInt(url.port, 10);
|
||||
}
|
||||
if (url.username) {
|
||||
options.user = decodeURIComponent(url.username);
|
||||
}
|
||||
if (url.password) {
|
||||
options.password = decodeURIComponent(url.password);
|
||||
}
|
||||
const database = decodeURIComponent(url.pathname.replace(/^\//, ""));
|
||||
if (database) {
|
||||
options.database = database;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an external Postgres DB sync connection string and returns the `pg` client options to
|
||||
* connect with. When SSRF protection is enforced and the target is a hostname, the connection is
|
||||
* pinned to a validated resolved address (with the hostname preserved for TLS SNI/cert verification)
|
||||
* so that `pg` cannot re-resolve to a different, internal address between our check and its connect
|
||||
* (DNS-rebinding TOCTOU). Throws a `StatusError` if the connection string is malformed or resolves
|
||||
* to a private/reserved address.
|
||||
*/
|
||||
export async function getSafeExternalPostgresClientOptions(connectionString: string): Promise<ClientConfig> {
|
||||
const url = parseExternalPostgresConnectionString(connectionString);
|
||||
const baseOptions: ClientConfig = {
|
||||
connectionString,
|
||||
connectionTimeoutMillis: EXTERNAL_DB_CONNECT_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
if (!shouldEnforceExternalDbSyncSsrfProtection()) {
|
||||
return baseOptions;
|
||||
}
|
||||
|
||||
const hostname = hostnameWithoutIpv6Brackets(url.hostname);
|
||||
if (isLocalhostName(hostname) || isBlockedPrivateOrReservedIpAddress(hostname)) {
|
||||
throw externalDbSyncSsrfProtectionError();
|
||||
}
|
||||
|
||||
// IP literals aren't re-resolved at connect time, so validating the literal above already removes
|
||||
// any rebinding window — connect straight from the original connection string.
|
||||
if (net.isIP(hostname) !== 0) {
|
||||
return baseOptions;
|
||||
}
|
||||
|
||||
const resolvedAddresses = await dns.lookup(hostname, { all: true, verbatim: true });
|
||||
if (resolvedAddresses.length === 0) {
|
||||
throw externalDbSyncSsrfProtectionError();
|
||||
}
|
||||
for (const resolved of resolvedAddresses) {
|
||||
assertPublicInternetResolvedAddress(resolved.address, externalDbSyncSsrfProtectionError());
|
||||
}
|
||||
|
||||
return buildPinnedPostgresClientOptions(url, resolvedAddresses[0].address, hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an external Postgres DB sync connection string, throwing a `StatusError` if it is unsafe.
|
||||
* Prefer {@link getSafeExternalPostgresClientOptions} when you are about to connect, as it also pins
|
||||
* the connection against DNS rebinding.
|
||||
*/
|
||||
export async function assertSafeExternalPostgresConnectionString(connectionString: string): Promise<void> {
|
||||
await getSafeExternalPostgresClientOptions(connectionString);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { StatusError } from "@hexclave/shared/dist/utils/errors";
|
||||
import dns from "node:dns";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { assertSafeOAuthResolvedAddress, assertSafeOAuthUrlWithoutDns, isBlockedOAuthIpAddress, safeOAuthDnsLookup } from "./ssrf-protection";
|
||||
import { assertSafeOAuthResolvedAddress, assertSafeOAuthUrlWithoutDns, isBlockedOAuthIpAddress, safeOAuthDnsLookup } from "./oauth";
|
||||
|
||||
async function withProductionOAuthSsrfProtection<T>(callback: () => Promise<T>): Promise<T> {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
@ -41,7 +41,10 @@ describe("isBlockedOAuthIpAddress", () => {
|
||||
|
||||
it("blocks IPv4-mapped IPv6 internal addresses", () => {
|
||||
expect(isBlockedOAuthIpAddress("::ffff:127.0.0.1")).toBe(true);
|
||||
expect(isBlockedOAuthIpAddress("::ffff:7f00:1")).toBe(true);
|
||||
expect(isBlockedOAuthIpAddress("0:0:0:0:0:ffff:7f00:1")).toBe(true);
|
||||
expect(isBlockedOAuthIpAddress("::ffff:169.254.169.254")).toBe(true);
|
||||
expect(isBlockedOAuthIpAddress("::ffff:a9fe:a9fe")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows public IP addresses", () => {
|
||||
73
apps/backend/src/lib/ssrf-protection/oauth.ts
Normal file
73
apps/backend/src/lib/ssrf-protection/oauth.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { getNodeEnvironment } from "@hexclave/shared/dist/utils/env";
|
||||
import { StatusError } from "@hexclave/shared/dist/utils/errors";
|
||||
import {
|
||||
DnsLookupCallback,
|
||||
assertHostnameResolvesToPublicInternet,
|
||||
assertPublicInternetResolvedAddress,
|
||||
hostnameWithoutIpv6Brackets,
|
||||
isBlockedPrivateOrReservedIpAddress,
|
||||
publicInternetDnsLookup,
|
||||
} from "./core";
|
||||
import dns from "node:dns";
|
||||
import net from "node:net";
|
||||
|
||||
const OAUTH_SSRF_PROTECTION_ERROR = "OAuth provider URLs must use HTTPS and resolve only to public internet addresses.";
|
||||
|
||||
function shouldEnforceOAuthSsrfProtection(): boolean {
|
||||
return !["development", "test"].includes(getNodeEnvironment());
|
||||
}
|
||||
|
||||
export function isBlockedOAuthIpAddress(address: string): boolean {
|
||||
return isBlockedPrivateOrReservedIpAddress(address);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
await assertHostnameResolvesToPublicInternet(hostname, new StatusError(StatusError.BadRequest, OAUTH_SSRF_PROTECTION_ERROR));
|
||||
}
|
||||
|
||||
export function assertSafeOAuthResolvedAddress(address: string): void {
|
||||
assertPublicInternetResolvedAddress(address, new StatusError(StatusError.BadRequest, OAUTH_SSRF_PROTECTION_ERROR));
|
||||
}
|
||||
|
||||
export function safeOAuthDnsLookup(hostname: string, options: dns.LookupOptions, callback: DnsLookupCallback): void {
|
||||
if (!shouldEnforceOAuthSsrfProtection()) {
|
||||
dns.lookup(hostname, options, callback);
|
||||
return;
|
||||
}
|
||||
|
||||
publicInternetDnsLookup(
|
||||
hostname,
|
||||
options,
|
||||
callback,
|
||||
new StatusError(StatusError.BadRequest, OAUTH_SSRF_PROTECTION_ERROR),
|
||||
);
|
||||
}
|
||||
122
apps/backend/src/lib/ssrf-protection/smtp.test.ts
Normal file
122
apps/backend/src/lib/ssrf-protection/smtp.test.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import dnsPromises from "node:dns/promises";
|
||||
import type { LookupAddress } from "node:dns";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { checkSmtpEgressPolicy, shouldEnforceSmtpEgressPolicy } from "./smtp";
|
||||
|
||||
function mockDnsLookup(addresses: LookupAddress[]) {
|
||||
return vi.spyOn(dnsPromises, "lookup").mockResolvedValue(addresses as never);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("checkSmtpEgressPolicy port allowlist", () => {
|
||||
it("rejects disallowed SMTP ports", async () => {
|
||||
await expect(checkSmtpEgressPolicy({ host: "203.0.113.10", port: 2526 })).resolves.toMatchObject({
|
||||
status: "error",
|
||||
violation: { reason: "disallowed-port" },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows the non-standard-but-permitted port 2525", async () => {
|
||||
await expect(checkSmtpEgressPolicy({ host: "8.8.8.8", port: 2525 })).resolves.toEqual({
|
||||
status: "ok",
|
||||
addresses: ["8.8.8.8"],
|
||||
connectHost: "8.8.8.8",
|
||||
servername: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkSmtpEgressPolicy IP literals", () => {
|
||||
it("rejects internal IP literals", async () => {
|
||||
const internalHosts = [
|
||||
"127.0.0.1",
|
||||
"10.0.0.1",
|
||||
"172.16.0.1",
|
||||
"192.168.0.1",
|
||||
"169.254.169.254",
|
||||
"::1",
|
||||
"::ffff:7f00:1",
|
||||
"fd00::1",
|
||||
"fe80::1",
|
||||
];
|
||||
|
||||
for (const host of internalHosts) {
|
||||
await expect(checkSmtpEgressPolicy({ host, port: 587 })).resolves.toMatchObject({
|
||||
status: "error",
|
||||
violation: { reason: "internal-ip-literal" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("allows public IP literals and does not set an SNI servername", async () => {
|
||||
await expect(checkSmtpEgressPolicy({ host: "8.8.8.8", port: 587 })).resolves.toEqual({
|
||||
status: "ok",
|
||||
addresses: ["8.8.8.8"],
|
||||
connectHost: "8.8.8.8",
|
||||
servername: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkSmtpEgressPolicy hostnames", () => {
|
||||
it("pins the connection to a validated resolved address and keeps the hostname for SNI", async () => {
|
||||
mockDnsLookup([{ address: "93.184.216.34", family: 4 }]);
|
||||
await expect(checkSmtpEgressPolicy({ host: "smtp.example.com", port: 587 })).resolves.toEqual({
|
||||
status: "ok",
|
||||
addresses: ["93.184.216.34"],
|
||||
connectHost: "93.184.216.34",
|
||||
servername: "smtp.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects hostnames that resolve to an internal address", async () => {
|
||||
mockDnsLookup([{ address: "10.0.0.5", family: 4 }]);
|
||||
await expect(checkSmtpEgressPolicy({ host: "sneaky.example.com", port: 587 })).resolves.toMatchObject({
|
||||
status: "error",
|
||||
violation: { reason: "internal-resolved-address", addresses: ["10.0.0.5"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects hostnames when every resolved address is internal even if one is public", async () => {
|
||||
mockDnsLookup([
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
{ address: "169.254.169.254", family: 4 },
|
||||
]);
|
||||
await expect(checkSmtpEgressPolicy({ host: "mixed.example.com", port: 587 })).resolves.toMatchObject({
|
||||
status: "error",
|
||||
violation: { reason: "internal-resolved-address", addresses: ["169.254.169.254"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports DNS lookup failures", async () => {
|
||||
vi.spyOn(dnsPromises, "lookup").mockRejectedValue(new Error("boom"));
|
||||
await expect(checkSmtpEgressPolicy({ host: "broken.example.com", port: 587 })).resolves.toMatchObject({
|
||||
status: "error",
|
||||
violation: { reason: "dns-lookup-failed" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldEnforceSmtpEgressPolicy", () => {
|
||||
it("is disabled in development and test", () => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
expect(shouldEnforceSmtpEgressPolicy()).toBe(false);
|
||||
vi.stubEnv("NODE_ENV", "test");
|
||||
expect(shouldEnforceSmtpEgressPolicy()).toBe(false);
|
||||
});
|
||||
|
||||
it("is enabled in production", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
expect(shouldEnforceSmtpEgressPolicy()).toBe(true);
|
||||
});
|
||||
|
||||
it("can be disabled by the operator escape hatch in production", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("HEXCLAVE_ALLOW_STANDARD_SMTP_PRIVATE_HOSTS", "true");
|
||||
expect(shouldEnforceSmtpEgressPolicy()).toBe(false);
|
||||
});
|
||||
});
|
||||
121
apps/backend/src/lib/ssrf-protection/smtp.ts
Normal file
121
apps/backend/src/lib/ssrf-protection/smtp.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { hostnameWithoutIpv6Brackets, isBlockedPrivateOrReservedIpAddress } from "./core";
|
||||
import { getEnvVariable, getNodeEnvironment } from "@hexclave/shared/dist/utils/env";
|
||||
import dns from "node:dns/promises";
|
||||
import net from "node:net";
|
||||
|
||||
export type SmtpEgressPolicyViolation = {
|
||||
reason: "disallowed-port" | "internal-ip-literal" | "internal-resolved-address" | "no-dns-addresses" | "dns-lookup-failed",
|
||||
host: string,
|
||||
port: number,
|
||||
addresses?: string[],
|
||||
cause?: unknown,
|
||||
};
|
||||
|
||||
export type SmtpEgressPolicyResult =
|
||||
| {
|
||||
status: "ok",
|
||||
addresses: string[],
|
||||
// The address the caller must actually connect to. For hostnames this is one of the validated
|
||||
// resolved addresses, so the SMTP client can't re-resolve the hostname to a different (internal)
|
||||
// address between our check and its connect — closing the DNS-rebinding TOCTOU window.
|
||||
connectHost: string,
|
||||
// The original hostname to use as the TLS SNI / certificate-identity name when connecting to the
|
||||
// pinned IP. `null` when the caller passed an IP literal (no SNI needed).
|
||||
servername: string | null,
|
||||
}
|
||||
| { status: "error", violation: SmtpEgressPolicyViolation };
|
||||
|
||||
const ALLOWED_SMTP_PORTS = new Set([25, 465, 587, 2465, 2587, 2525]);
|
||||
|
||||
/**
|
||||
* Whether the SMTP egress policy should be enforced for tenant-provided ("standard") email server
|
||||
* configs. Disabled in local development and tests, where custom SMTP configs legitimately point at
|
||||
* localhost (e.g. Inbucket), and can be disabled by self-host operators who intentionally relay
|
||||
* through an SMTP server on a private network.
|
||||
*/
|
||||
export function shouldEnforceSmtpEgressPolicy(): boolean {
|
||||
if (["development", "test"].includes(getNodeEnvironment())) {
|
||||
return false;
|
||||
}
|
||||
return getEnvVariable("HEXCLAVE_ALLOW_STANDARD_SMTP_PRIVATE_HOSTS", "false") !== "true";
|
||||
}
|
||||
|
||||
async function resolveSmtpHost(host: string): Promise<{ addresses: string[], errors: unknown[] }> {
|
||||
try {
|
||||
const lookupResults = await dns.lookup(hostnameWithoutIpv6Brackets(host), { all: true, verbatim: true });
|
||||
return {
|
||||
addresses: [...new Set(lookupResults.map((result) => result.address))],
|
||||
errors: [],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
addresses: [],
|
||||
errors: [error],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkSmtpEgressPolicy(options: {
|
||||
host: string,
|
||||
port: number,
|
||||
}): Promise<SmtpEgressPolicyResult> {
|
||||
if (!ALLOWED_SMTP_PORTS.has(options.port)) {
|
||||
return {
|
||||
status: "error",
|
||||
violation: {
|
||||
reason: "disallowed-port",
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedHost = hostnameWithoutIpv6Brackets(options.host);
|
||||
if (net.isIP(normalizedHost)) {
|
||||
if (isBlockedPrivateOrReservedIpAddress(normalizedHost)) {
|
||||
return {
|
||||
status: "error",
|
||||
violation: {
|
||||
reason: "internal-ip-literal",
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
addresses: [normalizedHost],
|
||||
},
|
||||
};
|
||||
}
|
||||
return { status: "ok", addresses: [normalizedHost], connectHost: normalizedHost, servername: null };
|
||||
}
|
||||
|
||||
const resolved = await resolveSmtpHost(options.host);
|
||||
if (resolved.addresses.length === 0) {
|
||||
return {
|
||||
status: "error",
|
||||
violation: {
|
||||
reason: resolved.errors.length > 0 ? "dns-lookup-failed" : "no-dns-addresses",
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
cause: resolved.errors,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const internalAddresses = resolved.addresses.filter(isBlockedPrivateOrReservedIpAddress);
|
||||
if (internalAddresses.length > 0) {
|
||||
return {
|
||||
status: "error",
|
||||
violation: {
|
||||
reason: "internal-resolved-address",
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
addresses: internalAddresses,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
addresses: resolved.addresses,
|
||||
connectHost: resolved.addresses[0],
|
||||
servername: normalizedHost,
|
||||
};
|
||||
}
|
||||
@ -4,7 +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 { assertSafeOAuthUrl, safeOAuthDnsLookup } from "@/lib/ssrf-protection/oauth";
|
||||
import { OAuthUserInfo } from "../utils";
|
||||
|
||||
const OAUTH_USERINFO_TOTAL_ATTEMPTS = 3;
|
||||
|
||||
@ -1,181 +0,0 @@
|
||||
import { getNodeEnvironment } from "@hexclave/shared/dist/utils/env";
|
||||
import { StatusError } from "@hexclave/shared/dist/utils/errors";
|
||||
import dns from "node:dns";
|
||||
import net from "node:net";
|
||||
|
||||
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 {
|
||||
const error = getUnsafeOAuthResolvedAddressError(address);
|
||||
if (error != null) throw error;
|
||||
}
|
||||
|
||||
function getUnsafeOAuthResolvedAddressError(address: string): StatusError | null {
|
||||
return isBlockedOAuthIpAddress(address)
|
||||
? new StatusError(StatusError.BadRequest, OAUTH_SSRF_PROTECTION_ERROR)
|
||||
: null;
|
||||
}
|
||||
|
||||
type DnsLookupCallback = (
|
||||
error: NodeJS.ErrnoException | null,
|
||||
address: string | dns.LookupAddress[],
|
||||
family?: number,
|
||||
) => void;
|
||||
|
||||
function getLookupValidationError(validate: () => void): NodeJS.ErrnoException | null {
|
||||
try {
|
||||
validate();
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
return new Error("OAuth DNS lookup failed while validating resolved address.");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const validationError = getLookupValidationError(() => {
|
||||
for (const address of addresses) {
|
||||
assertSafeOAuthResolvedAddress(address.address);
|
||||
}
|
||||
});
|
||||
if (validationError !== null) {
|
||||
callback(validationError, []);
|
||||
return;
|
||||
}
|
||||
callback(null, addresses);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const lookupOptions: dns.LookupOneOptions = { ...options, all: false };
|
||||
dns.lookup(hostname, lookupOptions, (error, address, family) => {
|
||||
if (error) {
|
||||
callback(error, "", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = getLookupValidationError(() => assertSafeOAuthResolvedAddress(address));
|
||||
if (validationError !== null) {
|
||||
callback(validationError, "", 0);
|
||||
return;
|
||||
}
|
||||
callback(null, address, family);
|
||||
});
|
||||
}
|
||||
@ -1 +1 @@
|
||||
Subproject commit 9f7eb33f1a4bdb38d2aef4e64a42efe423a925ec
|
||||
Subproject commit 965cf338896a9b0e4d26f6995953e68f939ac4cb
|
||||
@ -1,7 +1,6 @@
|
||||
import { AiProxyBodyProcessor } from "@/lib/ai/proxy-preprocessing";
|
||||
import { SignUpRiskEngine } from "@/lib/risk-scores";
|
||||
import { createNeutralSignUpHeuristicFacts } from "@/lib/sign-up-heuristics";
|
||||
import type { SmtpEgressPolicyResult } from "../types";
|
||||
|
||||
export const signUpRiskEngine: SignUpRiskEngine = {
|
||||
async calculateRiskAssessment() {
|
||||
@ -13,13 +12,3 @@ export const signUpRiskEngine: SignUpRiskEngine = {
|
||||
};
|
||||
|
||||
export const preprocessProxyBody: AiProxyBodyProcessor = ({ parsedBody }) => parsedBody;
|
||||
|
||||
export async function checkSmtpEgressPolicy(options: {
|
||||
host: string,
|
||||
port: number,
|
||||
}): Promise<SmtpEgressPolicyResult> {
|
||||
return {
|
||||
status: "ok",
|
||||
addresses: [options.host],
|
||||
};
|
||||
}
|
||||
|
||||
@ -1 +1 @@
|
||||
export { signUpRiskEngine, preprocessProxyBody, checkSmtpEgressPolicy } from "./implementation.generated";
|
||||
export { signUpRiskEngine, preprocessProxyBody } from "./implementation.generated";
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
export type SmtpEgressPolicyViolation = {
|
||||
reason: "disallowed-port" | "internal-ip-literal" | "internal-resolved-address" | "no-dns-addresses" | "dns-lookup-failed",
|
||||
host: string,
|
||||
port: number,
|
||||
addresses?: string[],
|
||||
cause?: unknown,
|
||||
};
|
||||
|
||||
export type SmtpEgressPolicyResult =
|
||||
| { status: "ok", addresses: string[] }
|
||||
| { status: "error", violation: SmtpEgressPolicyViolation };
|
||||
File diff suppressed because one or more lines are too long
@ -27,4 +27,5 @@ Below are some reminders on Hexclave and how to learn more about it. If you're s
|
||||
- If available, always prefer editing the `hexclave.config.ts` file directly over asking the user to make changes on the dashboard. When implementing new features, you can always update the config file, and then tell the user about the changes you've made. The config file is automatically synced when using the local dashboard/dev environment with `npx @hexclave/cli dev --config-file <path-to-config-file>`.
|
||||
- Hexclave's config files allow dot notation for nested properties. For example, the config `{ auth: { allowSignUp: true }, "auth.password": { allowSignIn: true } }` is the same as `{ auth: { allowSignUp: true, password: { allowSignIn: true } } }`.
|
||||
- You can use the `npx @hexclave/cli exec <javascript>` command to run JavaScript with a pre-configured HexclaveServerApp available as `hexclaveServerApp`. This allows you to read and write from and to the Hexclave project as you would on the dashboard, but from the CLI. To read and write project configuration, see the note on the config file above.
|
||||
- For advanced read queries, you can use `hexclaveServerApp.queryAnalytics("<clickhouse-sql>")`. Use `SHOW TABLES` and `DESCRIBE TABLE` to understand the schema of the available tables (columns have comments that may be useful as a description).
|
||||
- Hexclave was formerly known as Stack Auth. You may still see references to it as Stack Auth in some places.
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -244,8 +244,8 @@ const environmentSchemaFuzzerConfig = [{
|
||||
"some-external-db-id": [{
|
||||
type: ["postgres"] as const,
|
||||
connectionString: [
|
||||
"postgres://user:password@host:port/database",
|
||||
"some-connection-string",
|
||||
"postgres://user:password@db.example.com:5432/database",
|
||||
"postgresql://user:password@db.example.net/database",
|
||||
],
|
||||
}],
|
||||
}],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user