mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(security): harden auth and outbound fetches
This commit is contained in:
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user