mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
chore: post-2.0 QA hygiene across api hardening and qa metadata
This commit is contained in:
@@ -37,8 +37,10 @@ export async function isToolAuditEnabled(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
const AUDIT_STRIP_RE = /[<>&"'\r\n\0\x85\u2028\u2029]/g;
|
||||
|
||||
export function sanitizeAuditInput(raw: string): string {
|
||||
return raw.replace(/[<>&"']/g, "").slice(0, MAX_AUDIT_INPUT_LENGTH) || "(empty)";
|
||||
return raw.replace(AUDIT_STRIP_RE, "").slice(0, MAX_AUDIT_INPUT_LENGTH) || "(empty)";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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 } from "playwright";
|
||||
import { type Browser, chromium, type Page } from "playwright";
|
||||
import { isPrivateIp } from "./ssrf.js";
|
||||
|
||||
const MAX_PAGES = Math.max(1, parseInt(process.env.BROWSER_MAX_PAGES || "3", 10));
|
||||
const CRASH_WINDOW_MS = 60_000;
|
||||
@@ -56,6 +59,59 @@ function recordCrash(): void {
|
||||
backoffUntil = now + delay;
|
||||
}
|
||||
|
||||
async function isBlockedUrl(url: string): Promise<boolean> {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (
|
||||
parsed.protocol === "data:" ||
|
||||
parsed.protocol === "blob:" ||
|
||||
parsed.protocol === "about:"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function installNetworkGuard(page: Page): Promise<void> {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = route.request().url();
|
||||
if (await isBlockedUrl(url)) {
|
||||
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 });
|
||||
} catch {
|
||||
await route.abort("failed").catch(() => {});
|
||||
}
|
||||
});
|
||||
await page.routeWebSocket("**/*", (ws) => {
|
||||
ws.close();
|
||||
});
|
||||
}
|
||||
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
if (browserFailed) {
|
||||
throw new Error("Browser service permanently disabled after repeated crashes");
|
||||
@@ -67,7 +123,12 @@ async function getBrowser(): Promise<Browser> {
|
||||
return browser;
|
||||
}
|
||||
browser = await chromium.launch({
|
||||
args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-gpu",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-background-networking",
|
||||
],
|
||||
});
|
||||
browser.on("disconnected", () => {
|
||||
browser = null;
|
||||
@@ -88,7 +149,9 @@ export async function capturePage(url: string, options: CaptureOptions): Promise
|
||||
const page = await b.newPage({
|
||||
viewport: { width: options.viewportWidth, height: options.viewportHeight },
|
||||
isMobile: options.isMobile,
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
await installNetworkGuard(page);
|
||||
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "load", timeout: PAGE_LOAD_TIMEOUT });
|
||||
@@ -138,7 +201,9 @@ export async function captureHtml(html: string, options: CaptureOptions): Promis
|
||||
const page = await b.newPage({
|
||||
viewport: { width: options.viewportWidth, height: options.viewportHeight },
|
||||
isMobile: options.isMobile,
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
await installNetworkGuard(page);
|
||||
|
||||
try {
|
||||
await page.setContent(html, { waitUntil: "load", timeout: PAGE_LOAD_TIMEOUT });
|
||||
|
||||
@@ -46,6 +46,7 @@ const envSchema = z
|
||||
MAX_WORKER_THREADS: z.coerce.number().default(0),
|
||||
PROCESSING_TIMEOUT_S: z.coerce.number().default(0),
|
||||
MAX_PIPELINE_STEPS: z.coerce.number().default(20),
|
||||
MAX_PIPELINE_STEP_PIXELS: z.coerce.number().default(67_108_864),
|
||||
MAX_CANVAS_PIXELS: z.coerce.number().default(0),
|
||||
MAX_SVG_SIZE_MB: z.coerce.number().default(50),
|
||||
MAX_SPLIT_GRID: z.coerce.number().default(100),
|
||||
|
||||
@@ -50,6 +50,10 @@ function isPrivateIPv6(ip: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPrivateIp(ip: string): boolean {
|
||||
return isPrivateIPv4(ip) || isPrivateIPv6(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a hostname and validate all returned IPs are public.
|
||||
* Returns the first valid resolved IP so callers can pin it for the actual
|
||||
@@ -58,7 +62,7 @@ function isPrivateIPv6(ip: string): boolean {
|
||||
async function resolveAndCheck(hostname: string): Promise<string> {
|
||||
const bare = hostname.replace(/^\[|]$/g, "");
|
||||
if (isIP(bare)) {
|
||||
if (isPrivateIPv4(bare) || isPrivateIPv6(bare)) {
|
||||
if (isPrivateIp(bare)) {
|
||||
throw new Error("URL resolves to a private or reserved IP address");
|
||||
}
|
||||
return bare;
|
||||
@@ -67,8 +71,7 @@ async function resolveAndCheck(hostname: string): Promise<string> {
|
||||
const result = await lookup(hostname, { all: true });
|
||||
const addresses = Array.isArray(result) ? result : [result];
|
||||
for (const entry of addresses) {
|
||||
const addr = entry.address;
|
||||
if (isPrivateIPv4(addr) || isPrivateIPv6(addr)) {
|
||||
if (isPrivateIp(entry.address)) {
|
||||
throw new Error("URL resolves to a private or reserved IP address");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ function decodeNumericEntities(input: string): string {
|
||||
* Sanitize an SVG buffer to prevent XXE, SSRF, and script injection.
|
||||
* Throws if the SVG exceeds the maximum allowed size.
|
||||
*/
|
||||
const MAX_SVG_ELEMENTS = 5_000;
|
||||
|
||||
export function sanitizeSvg(buffer: Buffer): Buffer {
|
||||
const maxSvgSize = env.MAX_SVG_SIZE_MB > 0 ? env.MAX_SVG_SIZE_MB * 1024 * 1024 : Infinity;
|
||||
if (buffer.length > maxSvgSize) {
|
||||
@@ -25,6 +27,11 @@ export function sanitizeSvg(buffer: Buffer): Buffer {
|
||||
// ── Pre-processing: strip CDATA sections and decode numeric entities ──
|
||||
// CDATA sections can hide script content from regex-based checks.
|
||||
svg = svg.replace(/<!\[CDATA\[[\s\S]*?\]\]>/gi, "");
|
||||
|
||||
const elementCount = (svg.match(/<[a-zA-Z][^>]*\/?>/g) || []).length;
|
||||
if (elementCount > MAX_SVG_ELEMENTS) {
|
||||
throw new Error(`SVG exceeds maximum element count of ${MAX_SVG_ELEMENTS}`);
|
||||
}
|
||||
// Decode numeric entities so obfuscated URIs (e.g. javascript:) are visible.
|
||||
svg = decodeNumericEntities(svg);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user