Files
SnapOtter/tests/integration/security/adversarial.test.ts
T
SnapOtterandGitHub d10d0f544f fix: release QA hardening across processing, media, security, and CI gates (#649)
A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.

## Fixes that change behaviour

Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.

A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.

A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.

Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.

Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.

RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.

## Gates that could not fail

Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.

Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
2026-07-27 15:37:30 +08:00

558 lines
24 KiB
TypeScript

/**
* Adversarial integration tests for the SnapOtter image API.
*
* Tests malicious and stress inputs: corrupted images, truncated data,
* oversized images, invalid dimensions, injection attacks, and malformed
* multipart payloads.
*/
import { apiToolPath } from "@snapotter/shared";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../test-server.js";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const PNG_200x150 = readFixture(fixtures.image.base.png200);
// ---------------------------------------------------------------------------
// Shared state
// ---------------------------------------------------------------------------
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
/** Helper to POST a multipart payload to a tool endpoint. */
function postTool(
toolId: string,
fields: Array<{
name: string;
filename?: string;
contentType?: string;
content: Buffer | string;
}>,
) {
const { body, contentType } = createMultipartPayload(fields);
return app.inject({
method: "POST",
url: apiToolPath(toolId),
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
body,
});
}
// ═══════════════════════════════════════════════════════════════════════════
// CORRUPTED / TRUNCATED IMAGE DATA
// ═══════════════════════════════════════════════════════════════════════════
describe("Corrupted and truncated images", () => {
it("rejects JPEG magic bytes followed by garbage", async () => {
// Valid JPEG header (FF D8 FF E0) then random garbage
const corruptedJpeg = Buffer.concat([
Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
Buffer.from("this is not a real jpeg image at all, just garbage data"),
]);
const res = await postTool("resize", [
{ name: "file", filename: "corrupt.jpg", content: corruptedJpeg, contentType: "image/jpeg" },
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
// Should fail gracefully — 400 (validation) or 422 (processing)
expect([400, 422]).toContain(res.statusCode);
const json = JSON.parse(res.body);
expect(json.error).toBeDefined();
});
it("rejects a truncated PNG (first 100 bytes only)", async () => {
const truncated = PNG_200x150.subarray(0, 100);
const res = await postTool("resize", [
{ name: "file", filename: "truncated.png", content: truncated, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
// A truncated PNG still has valid magic bytes and Sharp can read partial
// metadata, so validation passes. Processing may succeed (partial decode)
// or fail gracefully. The key assertion: the server does not crash.
expect([200, 400, 422]).toContain(res.statusCode);
});
it("rejects random binary garbage as an image", async () => {
const garbage = Buffer.from(
Array.from({ length: 2048 }, () => Math.floor(Math.random() * 256)),
);
const res = await postTool("resize", [
{ name: "file", filename: "garbage.png", content: garbage, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect([400, 422]).toContain(res.statusCode);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// OVERSIZED IMAGE (MAX_MEGAPIXELS)
// ═══════════════════════════════════════════════════════════════════════════
describe("Image exceeding MAX_MEGAPIXELS", () => {
it("rejects an image exceeding the 100MP limit", async () => {
// Test env has MAX_MEGAPIXELS=100. A 10001x10001 image = ~100.02MP.
const huge = await sharp({
create: {
width: 10001,
height: 10001,
channels: 3,
background: { r: 0, g: 0, b: 0 },
},
})
.png({ compressionLevel: 9 })
.toBuffer();
const res = await postTool("resize", [
{ name: "file", filename: "huge.png", content: huge, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toMatch(/exceeds maximum size/i);
}, 60_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// INVALID DIMENSIONS IN SETTINGS
// ═══════════════════════════════════════════════════════════════════════════
describe("Invalid dimensions in settings", () => {
it("rejects negative width", async () => {
const res = await postTool("resize", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ width: -1 }) },
]);
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toMatch(/invalid settings/i);
});
it("rejects negative height", async () => {
const res = await postTool("resize", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ height: -1 }) },
]);
expect(res.statusCode).toBe(400);
});
it("rejects zero width", async () => {
const res = await postTool("resize", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ width: 0 }) },
]);
// z.number().positive() rejects 0
expect(res.statusCode).toBe(400);
});
it("handles float dimensions", async () => {
const res = await postTool("resize", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ width: 1.5 }) },
]);
// Zod allows floats through z.number().positive() — Sharp will round
// Should succeed or get rejected by Zod, but not crash
expect([200, 400, 422]).toContain(res.statusCode);
});
it("handles large width without crashing", async () => {
const res = await postTool("resize", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ width: 10000 }) },
]);
expect([200, 400, 422]).toContain(res.statusCode);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// INJECTION ATTACKS IN TEXT FIELDS
// ═══════════════════════════════════════════════════════════════════════════
describe("Injection attacks in text fields", () => {
it("SQL injection in text-overlay does not affect database", async () => {
const res = await postTool("text-overlay", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{
name: "settings",
content: JSON.stringify({
text: "'; DROP TABLE users; --",
fontSize: 24,
}),
},
]);
// Text overlay should process normally — SQL injection is in SVG text
expect(res.statusCode).toBe(200);
// Verify the database is still intact by making an auth request
const healthRes = await app.inject({
method: "GET",
url: "/api/v1/health",
});
expect(healthRes.statusCode).toBe(200);
});
it("XSS payload in text-overlay is treated as literal text", async () => {
const res = await postTool("text-overlay", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{
name: "settings",
content: JSON.stringify({
text: "<script>alert(1)</script>",
fontSize: 24,
}),
},
]);
// The escapeXml function should sanitize angle brackets
expect(res.statusCode).toBe(200);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// MALFORMED SETTINGS
// ═══════════════════════════════════════════════════════════════════════════
describe("Malformed settings field", () => {
it("rejects non-JSON settings string with 400", async () => {
const res = await postTool("resize", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: "not json at all" },
]);
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toMatch(/json/i);
});
it("rejects settings as JSON array with 400", async () => {
const res = await postTool("resize", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: "[]" },
]);
// Zod object schema should reject an array
expect(res.statusCode).toBe(400);
});
it("rejects settings as JSON string with 400", async () => {
const res = await postTool("resize", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: '"just a string"' },
]);
expect(res.statusCode).toBe(400);
});
it("rejects settings as JSON number with 400", async () => {
const res = await postTool("resize", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: "42" },
]);
expect(res.statusCode).toBe(400);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// DUPLICATE FILE FIELDS
// ═══════════════════════════════════════════════════════════════════════════
describe("Duplicate file fields in multipart", () => {
it("rejects a request with multiple file parts", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "first.png", content: PNG_200x150, contentType: "image/png" },
{ name: "file", filename: "second.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/resize",
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
body,
});
// Tool factory rejects multiple file parts with 400
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toMatch(/too many files/i);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// BINARY GARBAGE IN SETTINGS FIELD
// ═══════════════════════════════════════════════════════════════════════════
describe("Binary data in settings field", () => {
it("rejects random bytes as settings with 400", async () => {
const randomBytes = Buffer.from(
Array.from({ length: 64 }, () => Math.floor(Math.random() * 256)),
);
const res = await postTool("resize", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: randomBytes.toString("binary") },
]);
// Should return 400 for invalid JSON
expect(res.statusCode).toBe(400);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// ZERO-BYTE AND EMPTY FILE UPLOADS
// ═══════════════════════════════════════════════════════════════════════════
describe("Zero-byte and empty file uploads (adversarial)", () => {
it("rejects an empty file with a clear 400 error message", async () => {
const res = await postTool("resize", [
{ name: "file", filename: "empty.jpg", content: Buffer.alloc(0), contentType: "image/jpeg" },
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toBeDefined();
expect(json.error.toLowerCase()).toMatch(/no image|empty/i);
});
it("rejects a single null byte as an image", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "one-byte.png",
content: Buffer.from([0x00]),
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect([400, 422]).toContain(res.statusCode);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// WRONG MAGIC BYTES (PNG DATA WITH .jpg EXTENSION AND image/jpeg CONTENT TYPE)
// ═══════════════════════════════════════════════════════════════════════════
describe("Wrong magic bytes — format mismatch", () => {
it("handles PNG data uploaded as JPEG content type gracefully", async () => {
const res = await postTool("resize", [
{
name: "file",
filename: "really-a-png.jpg",
content: PNG_200x150,
contentType: "image/jpeg",
},
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
// Sharp detects via magic bytes, not content-type — should succeed
expect(res.statusCode).toBe(200);
});
it("rejects a text file renamed to .png", async () => {
const textAsImage = Buffer.from("This is just plain text, not an image at all.");
const res = await postTool("resize", [
{ name: "file", filename: "fake.png", content: textAsImage, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
expect([400, 422]).toContain(res.statusCode);
const json = JSON.parse(res.body);
expect(json.error).toBeDefined();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// TRUNCATED FILES — PARTIAL IMAGE DATA
// ═══════════════════════════════════════════════════════════════════════════
describe("Truncated files", () => {
it("handles first 50 bytes of a valid JPEG gracefully", async () => {
// Create a minimal JPEG-like header then truncate
const validJpeg = await sharp({
create: { width: 10, height: 10, channels: 3, background: { r: 255, g: 0, b: 0 } },
})
.jpeg()
.toBuffer();
const truncated = validJpeg.subarray(0, 50);
const res = await postTool("resize", [
{ name: "file", filename: "truncated.jpg", content: truncated, contentType: "image/jpeg" },
{ name: "settings", content: JSON.stringify({ width: 5 }) },
]);
// Must not crash; either 200 (partial decode), 400, or 422
expect([200, 400, 422]).toContain(res.statusCode);
});
it("handles first 8 bytes (just the PNG signature) gracefully", async () => {
// PNG signature is 8 bytes: 89 50 4E 47 0D 0A 1A 0A
const pngSignatureOnly = PNG_200x150.subarray(0, 8);
const res = await postTool("resize", [
{
name: "file",
filename: "sig-only.png",
content: pngSignatureOnly,
contentType: "image/png",
},
{ name: "settings", content: JSON.stringify({ width: 10 }) },
]);
expect([200, 400, 422]).toContain(res.statusCode);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// BINARY GARBAGE DATA — VARIOUS SIZES
// ═══════════════════════════════════════════════════════════════════════════
describe("Binary garbage data at various sizes", () => {
it("rejects 1 byte of garbage", async () => {
const res = await postTool("compress", [
{
name: "file",
filename: "tiny-garbage.jpg",
content: Buffer.from([0xab]),
contentType: "image/jpeg",
},
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
]);
expect([400, 422]).toContain(res.statusCode);
});
it("rejects 64KB of random data without crashing", async () => {
const garbage = Buffer.from(
Array.from({ length: 65536 }, () => Math.floor(Math.random() * 256)),
);
const res = await postTool("rotate", [
{ name: "file", filename: "big-garbage.png", content: garbage, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
]);
expect([400, 422]).toContain(res.statusCode);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// EXTREME DIMENSION REQUESTS
// ═══════════════════════════════════════════════════════════════════════════
describe("Extreme dimension requests", () => {
it("rejects resize to 0x0 dimensions", async () => {
const res = await postTool("resize", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ width: 0, height: 0 }) },
]);
expect(res.statusCode).toBe(400);
});
it("rejects resize to negative dimensions", async () => {
const res = await postTool("resize", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ width: -100, height: -50 }) },
]);
expect(res.statusCode).toBe(400);
});
it("handles resize to extremely large dimensions without crashing", async () => {
const res = await postTool("resize", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ width: 50000, height: 50000 }) },
]);
// May succeed (Sharp allows large), fail at processing (422), or be
// rejected by validation (400). Must not crash.
expect([200, 400, 422]).toContain(res.statusCode);
}, 120_000);
it("rejects crop region larger than image dimensions", async () => {
const res = await postTool("crop", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ left: 0, top: 0, width: 9999, height: 9999 }) },
]);
// Crop extends beyond image — Sharp will fail, should return 422
expect([400, 422]).toContain(res.statusCode);
});
it("rejects crop with offset beyond image bounds", async () => {
const res = await postTool("crop", [
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{
name: "settings",
content: JSON.stringify({ left: 500, top: 500, width: 10, height: 10 }),
},
]);
expect([400, 422]).toContain(res.statusCode);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// AUTH — UNAUTHENTICATED ACCESS
// ═══════════════════════════════════════════════════════════════════════════
describe("Unauthenticated tool access", () => {
it("rejects tool requests without auth token", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/resize",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
it("rejects tool requests with invalid auth token", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/resize",
headers: {
"content-type": contentType,
authorization: "Bearer totally-fake-token-12345",
},
body,
});
expect(res.statusCode).toBe(401);
});
});