Skip to content

Commit d4da06a

Browse files
feat(server): close security hardening M6 + M8
Sprint 3 batch — server-side hardening, no migrations, no env vars. M6 — Photo endpoints magic-byte enforcement - New apps/server/src/lib/imageMagic.ts: detectImageMime() inspects first 12 bytes; allowlist {jpeg,png,webp,heic}; 5 MB hard decode cap. - analyze-photo.ts + refine-photo.ts: validateImageBase64() before Anthropic call. INVALID_BASE64/MAGIC_MISMATCH/TRUNCATED -> 415, TOO_LARGE -> 413. - New metric nutrition_photo_rejected_total{endpoint,reason}, fixed cardinality 2 x 4 = 8. M8 — Tool-result envelope + prompt-injection scanner - New apps/server/src/modules/chat/toolOutputWrapping.ts: wrapAndScanToolResults() wraps each result in <tool_output tool="$NAME">...</tool_output>; closing tag inside content escaped with U+200B. - 8 conservative regex patterns scan for injection markers (ignore previous, <system>, jailbreak mode, etc.); metric-only, no rejection. - New metric chat_prompt_injection_attempt_total{tool}, cardinality bounded by whitelisted TOOLS (~25 values). - SYSTEM_PROMPT_VERSION v7 -> v8: prompt now instructs the model to treat <tool_output> content as data, not instructions. Cache prefix invalidates -> brief cache_creation spike expected on rollout. Tests: 86 new/touched tests pass. Lint + typecheck clean. Refs docs/security/hardening/M6-image-magic-byte-check.md Refs docs/security/hardening/M8-prompt-injection-tool-output.md Co-Authored-By: Ка А <dmytro.s.stakhov@gmail.com>
1 parent 5e3db06 commit d4da06a

16 files changed

Lines changed: 1329 additions & 13 deletions
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
/**
2+
* Unit tests для `imageMagic.ts` — server-side magic-byte валідатор для
3+
* `/api/nutrition/{analyze,refine}-photo`. Закриває M6.
4+
*/
5+
import { describe, it, expect } from "vitest";
6+
import {
7+
detectImageMime,
8+
validateImageBase64,
9+
ALLOWED_PHOTO_MIMES,
10+
MAX_DECODED_BYTES,
11+
} from "./imageMagic.js";
12+
13+
/** 12-байтова JPEG-голівка з корректним маркером SOI + JFIF-app0. */
14+
const JPEG_HEADER = Buffer.from([
15+
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01,
16+
]);
17+
/** PNG signature + перший chunk (IHDR header, не повний PNG). */
18+
const PNG_HEADER = Buffer.from([
19+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
20+
]);
21+
/** RIFF header + WEBP signature + 4 байти контенту. */
22+
const WEBP_HEADER = Buffer.from([
23+
0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
24+
]);
25+
/** HEIC: bytes 4..7 = "ftyp", brand "heic". */
26+
const HEIC_HEADER = Buffer.from([
27+
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63,
28+
]);
29+
/** HEIF з brand "mif1" (також HEIC family). */
30+
const HEIF_MIF1_HEADER = Buffer.from([
31+
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x69, 0x66, 0x31,
32+
]);
33+
/** GIF89a — розпізнається, але поза allowlist. */
34+
const GIF_HEADER = Buffer.from([
35+
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0, 0, 0, 0, 0, 0,
36+
]);
37+
/** SVG-як-HTML polyglot: починається з '<'. */
38+
const SVG_HEADER = Buffer.from('<svg xmlns="', "utf8");
39+
40+
function asBase64(b: Buffer): string {
41+
return b.toString("base64");
42+
}
43+
44+
describe("detectImageMime", () => {
45+
it.each([
46+
["JPEG SOI+JFIF", JPEG_HEADER, "image/jpeg"],
47+
["PNG signature", PNG_HEADER, "image/png"],
48+
["WebP RIFF+WEBP", WEBP_HEADER, "image/webp"],
49+
["HEIC ftyp+heic", HEIC_HEADER, "image/heic"],
50+
["HEIF ftyp+mif1", HEIF_MIF1_HEADER, "image/heic"],
51+
["GIF89a (recognised but not in allowlist)", GIF_HEADER, "image/gif"],
52+
["SVG/HTML polyglot ('<' prefix)", SVG_HEADER, "text/xml"],
53+
])("розпізнає %s", (_label, bytes, expected) => {
54+
expect(detectImageMime(bytes)).toBe(expected);
55+
});
56+
57+
it("повертає null на невпізнаних байтах", () => {
58+
expect(
59+
detectImageMime(
60+
Buffer.from([0xde, 0xad, 0xbe, 0xef, 0, 0, 0, 0, 0, 0, 0, 0]),
61+
),
62+
).toBe(null);
63+
});
64+
65+
it("повертає null на повністю нульових байтах", () => {
66+
expect(detectImageMime(Buffer.alloc(20))).toBe(null);
67+
});
68+
69+
it("повертає null на занадто короткому буфері (<3 байт)", () => {
70+
expect(detectImageMime(Buffer.from([0xff, 0xd8]))).toBe(null);
71+
});
72+
});
73+
74+
describe("validateImageBase64 — happy paths", () => {
75+
it("JPEG з правильно вказаним mime-type → ok з канонічним image/jpeg", () => {
76+
// JPEG не вимагає мінімум 12 байт магії, але загальна перевірка довжини потребує ≥12.
77+
const padded = Buffer.concat([JPEG_HEADER, Buffer.alloc(20)]);
78+
const r = validateImageBase64(asBase64(padded), "image/jpeg");
79+
expect(r).toMatchObject({
80+
ok: true,
81+
mimeType: "image/jpeg",
82+
sizeBytes: padded.length,
83+
});
84+
});
85+
86+
it("PNG без вказаного mime-type → ok (mime detected з magic)", () => {
87+
const padded = Buffer.concat([PNG_HEADER, Buffer.alloc(20)]);
88+
const r = validateImageBase64(asBase64(padded), undefined);
89+
expect(r).toMatchObject({ ok: true, mimeType: "image/png" });
90+
});
91+
92+
it("WebP з charset-suffix у MIME-header → strip + match", () => {
93+
const padded = Buffer.concat([WEBP_HEADER, Buffer.alloc(20)]);
94+
const r = validateImageBase64(
95+
asBase64(padded),
96+
"image/webp; charset=binary",
97+
);
98+
expect(r).toMatchObject({ ok: true, mimeType: "image/webp" });
99+
});
100+
101+
it("HEIC з MIXED CASE mime-type → нормалізує", () => {
102+
const padded = Buffer.concat([HEIC_HEADER, Buffer.alloc(20)]);
103+
const r = validateImageBase64(asBase64(padded), "Image/HEIC");
104+
expect(r).toMatchObject({ ok: true, mimeType: "image/heic" });
105+
});
106+
});
107+
108+
describe("validateImageBase64 — rejection paths", () => {
109+
it("PNG bytes, declared as image/jpeg → MAGIC_MISMATCH", () => {
110+
const padded = Buffer.concat([PNG_HEADER, Buffer.alloc(20)]);
111+
const r = validateImageBase64(asBase64(padded), "image/jpeg");
112+
expect(r.ok).toBe(false);
113+
if (!r.ok) {
114+
expect(r.code).toBe("MAGIC_MISMATCH");
115+
expect(r).toMatchObject({
116+
declaredMime: "image/jpeg",
117+
detectedMime: "image/png",
118+
});
119+
}
120+
});
121+
122+
it("SVG polyglot, declared as image/jpeg → MAGIC_MISMATCH (detected=text/xml)", () => {
123+
const padded = Buffer.concat([SVG_HEADER, Buffer.alloc(20)]);
124+
const r = validateImageBase64(asBase64(padded), "image/jpeg");
125+
expect(r.ok).toBe(false);
126+
if (!r.ok) {
127+
expect(r.code).toBe("MAGIC_MISMATCH");
128+
expect(r).toMatchObject({ detectedMime: "text/xml" });
129+
}
130+
});
131+
132+
it("GIF (recognised but not in allowlist) → MAGIC_MISMATCH з explicit detail", () => {
133+
const padded = Buffer.concat([GIF_HEADER, Buffer.alloc(20)]);
134+
const r = validateImageBase64(asBase64(padded), "image/gif");
135+
expect(r.ok).toBe(false);
136+
if (!r.ok) {
137+
expect(r.code).toBe("MAGIC_MISMATCH");
138+
expect(r.detail).toContain("only");
139+
expect(r).toMatchObject({ detectedMime: "image/gif" });
140+
}
141+
});
142+
143+
it("Truncated payload (8 байтів) → TRUNCATED", () => {
144+
const tiny = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]);
145+
const r = validateImageBase64(asBase64(tiny), "image/jpeg");
146+
expect(r.ok).toBe(false);
147+
if (!r.ok && r.code === "TRUNCATED") {
148+
expect(r.sizeBytes).toBe(8);
149+
} else {
150+
throw new Error(
151+
`expected TRUNCATED, got ${(r as { code?: string }).code}`,
152+
);
153+
}
154+
});
155+
156+
it("Decoded > 5 MB → TOO_LARGE", () => {
157+
const big = Buffer.concat([JPEG_HEADER, Buffer.alloc(MAX_DECODED_BYTES)]);
158+
const r = validateImageBase64(asBase64(big), "image/jpeg");
159+
expect(r.ok).toBe(false);
160+
if (!r.ok && r.code === "TOO_LARGE") {
161+
expect(r.sizeBytes).toBe(big.length);
162+
} else {
163+
throw new Error(
164+
`expected TOO_LARGE, got ${(r as { code?: string }).code}`,
165+
);
166+
}
167+
});
168+
169+
it("Розпізнані arbitrary bytes без сигнатури → MAGIC_MISMATCH (detectedMime=null)", () => {
170+
const garbage = Buffer.alloc(32, 0xab);
171+
const r = validateImageBase64(asBase64(garbage), "image/jpeg");
172+
expect(r.ok).toBe(false);
173+
if (!r.ok && r.code === "MAGIC_MISMATCH") {
174+
expect(r.detectedMime).toBe(null);
175+
} else {
176+
throw new Error(
177+
`expected MAGIC_MISMATCH, got ${(r as { code?: string }).code}`,
178+
);
179+
}
180+
});
181+
182+
it("Не-base64 символи у вході → INVALID_BASE64", () => {
183+
const r = validateImageBase64("not base64 at all !!!", "image/jpeg");
184+
expect(r.ok).toBe(false);
185+
if (!r.ok) {
186+
expect(r.code).toBe("INVALID_BASE64");
187+
}
188+
});
189+
190+
it("Порожній рядок → INVALID_BASE64", () => {
191+
const r = validateImageBase64("", "image/jpeg");
192+
expect(r.ok).toBe(false);
193+
if (!r.ok) {
194+
expect(r.code).toBe("INVALID_BASE64");
195+
}
196+
});
197+
198+
it("Custom maxBytes ефективно знижує cap (regression-проти hard-coded 5MB)", () => {
199+
const padded = Buffer.concat([JPEG_HEADER, Buffer.alloc(1024)]);
200+
const r = validateImageBase64(asBase64(padded), "image/jpeg", {
201+
maxBytes: 100,
202+
});
203+
expect(r.ok).toBe(false);
204+
if (!r.ok && r.code === "TOO_LARGE") {
205+
expect(r.maxBytes).toBe(100);
206+
} else {
207+
throw new Error(
208+
`expected TOO_LARGE, got ${(r as { code?: string }).code}`,
209+
);
210+
}
211+
});
212+
213+
it("Custom allowedMimes зробити вужчим (тільки image/jpeg) ріже PNG", () => {
214+
const padded = Buffer.concat([PNG_HEADER, Buffer.alloc(20)]);
215+
const r = validateImageBase64(asBase64(padded), undefined, {
216+
allowedMimes: new Set(["image/jpeg"]),
217+
});
218+
expect(r.ok).toBe(false);
219+
if (!r.ok) {
220+
expect(r.code).toBe("MAGIC_MISMATCH");
221+
expect(r.detail).toContain("only image/jpeg");
222+
}
223+
});
224+
});
225+
226+
describe("ALLOWED_PHOTO_MIMES — invariant", () => {
227+
it("обмежено саме чотирма канонічними MIME-ами (cardinality сейф для метрик)", () => {
228+
expect(ALLOWED_PHOTO_MIMES.size).toBe(4);
229+
expect(ALLOWED_PHOTO_MIMES.has("image/jpeg")).toBe(true);
230+
expect(ALLOWED_PHOTO_MIMES.has("image/png")).toBe(true);
231+
expect(ALLOWED_PHOTO_MIMES.has("image/webp")).toBe(true);
232+
expect(ALLOWED_PHOTO_MIMES.has("image/heic")).toBe(true);
233+
expect(ALLOWED_PHOTO_MIMES.has("image/gif")).toBe(false);
234+
expect(ALLOWED_PHOTO_MIMES.has("image/svg+xml")).toBe(false);
235+
});
236+
});

0 commit comments

Comments
 (0)