fix(security): harden auth and outbound fetches

This commit is contained in:
SnapOtter
2026-06-29 17:54:12 +08:00
parent 6f85b3d12a
commit c6319cf8a9
34 changed files with 799 additions and 173 deletions
+51 -35
View File
@@ -1,9 +1,7 @@
import { lookup } from "node:dns/promises";
import { existsSync } from "node:fs";
import { isIP } from "node:net";
import PQueue from "p-queue";
import { type Browser, chromium, type Page } from "playwright";
import { isPrivateIp } from "./ssrf.js";
import { MAX_URL_FETCH_SIZE, safeFetch } from "./ssrf.js";
const MAX_PAGES = Math.max(1, parseInt(process.env.BROWSER_MAX_PAGES || "3", 10));
const CRASH_WINDOW_MS = 60_000;
@@ -59,50 +57,68 @@ function recordCrash(): void {
backoffUntil = now + delay;
}
async function isBlockedUrl(url: string): Promise<boolean> {
try {
const parsed = new URL(url);
function isBrowserLocalUrl(url: string): boolean {
const parsed = new URL(url);
return parsed.protocol === "data:" || parsed.protocol === "blob:" || parsed.protocol === "about:";
}
function responseHeadersForBrowser(response: Response): Record<string, string> {
const headers: Record<string, string> = {};
response.headers.forEach((value, key) => {
if (
parsed.protocol === "data:" ||
parsed.protocol === "blob:" ||
parsed.protocol === "about:"
key === "connection" ||
key === "content-encoding" ||
key === "content-length" ||
key === "transfer-encoding"
) {
return false;
return;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return true;
}
const hostname = parsed.hostname.replace(/^\[|]$/g, "");
if (isIP(hostname)) {
return isPrivateIp(hostname);
}
try {
const result = await lookup(hostname, { all: true });
const entries = Array.isArray(result) ? result : [result];
return entries.some((e) => isPrivateIp(e.address));
} catch {
return true;
}
} catch {
return true;
}
headers[key] = value;
});
return headers;
}
async function installNetworkGuard(page: Page): Promise<void> {
await page.route("**/*", async (route) => {
const url = route.request().url();
if (await isBlockedUrl(url)) {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
await route.abort("blockedbyclient").catch(() => {});
return;
}
if (isBrowserLocalUrl(url)) {
await route.continue().catch(() => {});
return;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
await route.abort("blockedbyclient").catch(() => {});
return;
}
const method = route.request().method();
if (method !== "GET" && method !== "HEAD") {
await route.abort("blockedbyclient").catch(() => {});
return;
}
try {
const response = await route.fetch();
const responseUrl = response.url();
if (responseUrl !== url && (await isBlockedUrl(responseUrl))) {
await route.abort("blockedbyclient").catch(() => {});
return;
}
await route.fulfill({ response });
const response = await safeFetch(url, {
method,
maxBytes: MAX_URL_FETCH_SIZE,
headers: {
Accept: route.request().headers().accept ?? "*/*",
},
});
const body = method === "HEAD" ? undefined : Buffer.from(await response.arrayBuffer());
await route.fulfill({
status: response.status,
headers: responseHeadersForBrowser(response),
body,
});
} catch {
await route.abort("failed").catch(() => {});
}
+2 -2
View File
@@ -28,8 +28,8 @@ const envSchema = z
SNAPOTTER_LICENSE_KEY: z.string().default(""),
FILE_MAX_AGE_HOURS: z.coerce.number().default(72),
CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(60),
MAX_UPLOAD_SIZE_MB: z.coerce.number().default(0),
MAX_BATCH_SIZE: z.coerce.number().default(0),
MAX_UPLOAD_SIZE_MB: z.coerce.number().default(100),
MAX_BATCH_SIZE: z.coerce.number().default(100),
CONCURRENT_JOBS: z.coerce.number().default(0),
MAX_MEGAPIXELS: z.coerce.number().default(0),
RATE_LIMIT_PER_MIN: z.coerce.number().default(300),
+28 -3
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import type { FastifyBaseLogger } from "fastify";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { isDisabledRole } from "../permissions.js";
import { auditLog, sanitizeAuditInput } from "./audit.js";
// ── Types ─────────────────────────────────────────────────────────
@@ -24,7 +25,7 @@ export interface ExternalAuthParams {
export interface ExternalAuthResult {
user: { id: string; username: string; role: string; team: string } | null;
action: "matched" | "linked" | "created" | "denied";
deniedReason?: "user_not_authorized" | "user_limit_reached";
deniedReason?: "user_not_authorized" | "user_limit_reached" | "user_disabled";
}
// ── Username helpers ──────────────────────────────────────────────
@@ -94,10 +95,18 @@ export async function resolveExternalUser(params: ExternalAuthParams): Promise<E
const [existingByExtId] = await db
.select()
.from(schema.users)
.where(eq(schema.users.externalId, externalId))
.where(and(eq(schema.users.externalId, externalId), eq(schema.users.authProvider, provider)))
.limit(1);
if (existingByExtId) {
if (isDisabledRole(existingByExtId.role)) {
await audit(`${providerUpper}_LOGIN_FAILED`, {
reason: "user_disabled",
userId: existingByExtId.id,
});
return { user: null, action: "denied", deniedReason: "user_disabled" };
}
// Update email if changed
if (email && email !== existingByExtId.email) {
await db
@@ -125,6 +134,14 @@ export async function resolveExternalUser(params: ExternalAuthParams): Promise<E
.limit(1);
if (existingByEmail) {
if (isDisabledRole(existingByEmail.role)) {
await audit(`${providerUpper}_LOGIN_FAILED`, {
reason: "user_disabled",
userId: existingByEmail.id,
});
return { user: null, action: "denied", deniedReason: "user_disabled" };
}
await db
.update(schema.users)
.set({
@@ -154,6 +171,14 @@ export async function resolveExternalUser(params: ExternalAuthParams): Promise<E
// 3. Auto-create
if (autoCreate) {
if (isDisabledRole(defaultRole)) {
logger.warn(`${provider} auto-create blocked: default role is disabled`);
await audit(`${providerUpper}_LOGIN_FAILED`, {
reason: "user_disabled",
});
return { user: null, action: "denied", deniedReason: "user_disabled" };
}
// Check user limit
if (env.MAX_USERS > 0) {
const [countResult] = await db.select({ count: sql<number>`COUNT(*)` }).from(schema.users);
+73 -7
View File
@@ -124,6 +124,14 @@ export const MAX_URL_FETCH_SIZE = 50 * 1024 * 1024;
export const MAX_URLS_PER_REQUEST = 50;
export const URL_FETCH_CONCURRENCY = 4;
export interface SafeFetchOptions {
signal?: AbortSignal;
maxBytes?: number;
method?: string;
headers?: Record<string, string>;
body?: BodyInit | Buffer | string;
}
/**
* Create an HTTP(S) agent that pins DNS resolution to a specific IP address.
* This prevents DNS rebinding attacks where a hostname resolves to a different
@@ -158,7 +166,41 @@ function createPinnedAgent(resolvedIp: string, protocol: string): http.Agent | h
return new http.Agent({ lookup: pinnedLookup as never, maxSockets: 1 });
}
export async function safeFetch(url: string, signal?: AbortSignal): Promise<Response> {
function normalizeSafeFetchOptions(options?: AbortSignal | SafeFetchOptions): SafeFetchOptions {
if (!options) return {};
if ("aborted" in options && "addEventListener" in options) return { signal: options };
return options;
}
function withResponseSizeLimit(response: Response, maxBytes?: number): Response {
if (maxBytes === undefined || !response.body) return response;
let totalBytes = 0;
const limitedBody = response.body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
totalBytes += chunk.byteLength;
if (totalBytes > maxBytes) {
controller.error(new Error(`Response exceeds maximum size of ${maxBytes} bytes`));
return;
}
controller.enqueue(chunk);
},
}),
);
return new Response(limitedBody, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
export async function safeFetch(
url: string,
options?: AbortSignal | SafeFetchOptions,
): Promise<Response> {
const safeOptions = normalizeSafeFetchOptions(options);
let currentUrl = url;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
const { resolvedIp } = await validateFetchUrl(currentUrl);
@@ -177,12 +219,15 @@ export async function safeFetch(url: string, signal?: AbortSignal): Promise<Resp
}
const fetchOptions: RequestInit & { agent?: http.Agent | https.Agent } = {
signal,
signal: safeOptions.signal,
redirect: "manual",
method: safeOptions.method ?? "GET",
headers: {
"User-Agent": "SnapOtter/2.0 (file-fetch)",
Host: parsed.host,
...safeOptions.headers,
},
body: safeOptions.body as BodyInit | null | undefined,
};
// Node.js undici-based fetch does not support the `agent` option directly.
@@ -196,16 +241,32 @@ export async function safeFetch(url: string, signal?: AbortSignal): Promise<Resp
currentUrl,
{
agent,
signal: signal ?? undefined,
signal: safeOptions.signal ?? undefined,
headers: {
"User-Agent": "SnapOtter/2.0 (file-fetch)",
...safeOptions.headers,
},
method: "GET",
method: safeOptions.method ?? "GET",
},
(incomingMessage) => {
const chunks: Buffer[] = [];
incomingMessage.on("data", (chunk: Buffer) => chunks.push(chunk));
let totalBytes = 0;
let settled = false;
incomingMessage.on("data", (chunk: Buffer) => {
if (settled) return;
totalBytes += chunk.length;
if (safeOptions.maxBytes !== undefined && totalBytes > safeOptions.maxBytes) {
settled = true;
req.destroy(
new Error(`Response exceeds maximum size of ${safeOptions.maxBytes} bytes`),
);
return;
}
chunks.push(chunk);
});
incomingMessage.on("end", () => {
if (settled) return;
settled = true;
const body = Buffer.concat(chunks);
const headers = new Headers();
for (const [key, value] of Object.entries(incomingMessage.headers)) {
@@ -222,10 +283,15 @@ export async function safeFetch(url: string, signal?: AbortSignal): Promise<Resp
}),
);
});
incomingMessage.on("error", reject);
incomingMessage.on("error", (err) => {
if (settled) return;
settled = true;
reject(err);
});
},
);
req.on("error", reject);
if (safeOptions.body) req.write(safeOptions.body);
req.end();
});
} else {
@@ -241,7 +307,7 @@ export async function safeFetch(url: string, signal?: AbortSignal): Promise<Resp
continue;
}
return res;
return withResponseSizeLimit(res, safeOptions.maxBytes);
}
throw new Error("Too many redirects");
}
+4 -1
View File
@@ -1,3 +1,5 @@
import { safeFetch } from "./ssrf.js";
interface DeliveryOptions {
maxRetries?: number;
initialDelayMs?: number;
@@ -36,11 +38,12 @@ export async function deliverWebhook(
}
try {
const response = await fetch(url, {
const response = await safeFetch(url, {
method: "POST",
headers,
body: payload,
signal: AbortSignal.timeout(timeoutMs),
maxBytes: 1024 * 1024,
});
if (response.ok) {