mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Auth: login rate limit 30/min (was 500), global rate limit 1000/min (was unlimited), password/username max lengths on all Zod schemas, session invalidation on role change, API key legacy scan bounded to 100 keys. SVG: hardened regex sanitizer with CDATA stripping, XML entity decoding, set/animate/iframe/embed blocking, comprehensive data: URI blocking, use element external href blocking. 11 attack payload fixtures added. SSRF: fixed DNS rebinding TOCTOU by pinning resolved IPs via custom HTTP/HTTPS agents. Added 6to4 and NAT64 to blocked IPv6 ranges. Docker: capability dropping (cap_drop ALL + minimal cap_add), resource limits (4g/8g mem, 512/1024 pids), healthcheck timeout, password removed from startup banner, default password warning comments. Network: CSP and HSTS applied in all environments (not just production), stack traces removed from all error responses, internal paths stripped from error details, per-route rate limits on uploads (60/min) and URL fetches (200/hour). Files: exclusive temp file creation (O_EXCL), disk space circuit breaker, per-user storage quotas, settings payload 64KB size guard. Python sidecar: script name allowlist in dispatcher, minimal environment for subprocess spawns. Dependencies: fixed 6 production CVEs (drizzle-orm, fastify, fast-uri, @fastify/static, next, archiver/lodash). Pinned all GitHub Actions to SHA hashes. 114 security tests added. Full OWASP Top 10 penetration test matrix verified against production Docker container (30/30 pass after hardening).
119 lines
4.0 KiB
TypeScript
119 lines
4.0 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { constants } from "node:fs";
|
|
import { open, readFile, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
/**
|
|
* Write a buffer to a temp file exclusively (O_CREAT | O_EXCL | O_WRONLY).
|
|
* Prevents symlink / race-condition attacks on predictable temp paths.
|
|
*/
|
|
async function writeTempExclusive(filePath: string, buffer: Buffer): Promise<void> {
|
|
const fh = await open(filePath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
|
|
try {
|
|
await fh.writeFile(buffer);
|
|
} finally {
|
|
await fh.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the HEIF decode command. Both heif-convert and heif-dec accept
|
|
* `<input> <output>` positional arguments.
|
|
*/
|
|
let cachedDecodeCmd: string | null = null;
|
|
|
|
async function findDecodeCmd(): Promise<string> {
|
|
if (cachedDecodeCmd) return cachedDecodeCmd;
|
|
for (const cmd of ["heif-convert", "heif-dec"]) {
|
|
try {
|
|
await execFileAsync(cmd, ["--version"], { timeout: 5_000 });
|
|
cachedDecodeCmd = cmd;
|
|
return cmd;
|
|
} catch {
|
|
// try next
|
|
}
|
|
}
|
|
throw new Error("No HEIF decoder found. Install libheif-examples (Linux) or libheif (macOS).");
|
|
}
|
|
|
|
/**
|
|
* Decode a HEIC/HEIF buffer to PNG using the system HEIF decoder CLI.
|
|
* This is needed because Sharp's bundled libheif does not include the
|
|
* HEVC decoder required for true HEIC files (iPhone photos).
|
|
*
|
|
* Multi-image HEIF files (common from iPhones) cause heif-convert/heif-dec
|
|
* to add numeric suffixes (-1, -2, ...) to the output filename. We try the
|
|
* exact path first, then fall back to the -1 suffixed path.
|
|
*/
|
|
export async function decodeHeic(buffer: Buffer): Promise<Buffer> {
|
|
const cmd = await findDecodeCmd();
|
|
const id = randomUUID();
|
|
const inputPath = join(tmpdir(), `heic-in-${id}.heic`);
|
|
const outputPath = join(tmpdir(), `heic-out-${id}.png`);
|
|
const suffixedPath = outputPath.replace(/\.png$/, "-1.png");
|
|
|
|
try {
|
|
await writeTempExclusive(inputPath, buffer);
|
|
await execFileAsync(cmd, [inputPath, outputPath], { timeout: 120_000 });
|
|
|
|
// Single-image HEIF: exact filename. Multi-image: -1 suffix on first image.
|
|
try {
|
|
return await readFile(outputPath);
|
|
} catch {
|
|
return await readFile(suffixedPath);
|
|
}
|
|
} finally {
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
|
await rm(suffixedPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Encode a PNG/JPEG buffer to HEIC using the system `heif-enc` CLI tool.
|
|
* Uses x265 (HEVC) compression for true HEIC output.
|
|
*/
|
|
/**
|
|
* Detect HEIC/HEIF format from magic bytes (ftyp box at offset 4, brand at offset 8).
|
|
*/
|
|
function isHeifBuffer(buffer: Buffer): boolean {
|
|
if (buffer.length < 12) return false;
|
|
const ftyp = buffer.subarray(4, 8).toString("ascii");
|
|
if (ftyp !== "ftyp") return false;
|
|
const brand = buffer.subarray(8, 12).toString("ascii");
|
|
return ["heic", "heix", "mif1", "msf1", "hevc", "hevx"].includes(brand);
|
|
}
|
|
|
|
/**
|
|
* Ensure a buffer is decodable by Sharp. HEIC/HEIF buffers are decoded to
|
|
* PNG via the system decoder; all other formats pass through unchanged.
|
|
*/
|
|
export async function ensureSharpCompat(buffer: Buffer): Promise<Buffer> {
|
|
if (isHeifBuffer(buffer)) {
|
|
return decodeHeic(buffer);
|
|
}
|
|
return buffer;
|
|
}
|
|
|
|
export async function encodeHeic(buffer: Buffer, quality = 80): Promise<Buffer> {
|
|
const id = randomUUID();
|
|
const inputPath = join(tmpdir(), `heic-in-${id}.png`);
|
|
const outputPath = join(tmpdir(), `heic-out-${id}.heic`);
|
|
|
|
try {
|
|
await writeTempExclusive(inputPath, buffer);
|
|
await execFileAsync("heif-enc", ["-q", String(quality), "-o", outputPath, inputPath], {
|
|
timeout: 120_000,
|
|
});
|
|
return await readFile(outputPath);
|
|
} finally {
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|