security: workbook path allowlist + stronger image polyglot validation (#54)
- dispo workbook imports are pinned to DISPO_IMPORT_DIR (default ./imports): tRPC input rejects absolute paths and .. segments, runtime reader re-validates containment via path.relative. Closes a path-traversal class that reached ExcelJS CVEs through admin/compromised tokens. - image validator now checks the full 8-byte PNG magic, enforces PNG IEND and JPEG EOI trailers, scans the decoded buffer for markup polyglot markers (<script, <svg, <iframe, javascript:, onerror=, ...), and explicitly rejects SVG. Provider-generated covers (DALL-E, Gemini) run through the same validator before persistence — an untrusted upstream cannot smuggle a stored-XSS payload past us. - added image-validation.test.ts and tightened documentation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -58,22 +58,22 @@ describe("assistant dispo import batch delegation tools", () => {
|
||||
const result = await executeTool(
|
||||
"stage_dispo_import_batch",
|
||||
JSON.stringify({
|
||||
chargeabilityWorkbookPath: "/imports/chargeability.xlsx",
|
||||
planningWorkbookPath: "/imports/planning.xlsx",
|
||||
referenceWorkbookPath: "/imports/reference.xlsx",
|
||||
costWorkbookPath: "/imports/cost.xlsx",
|
||||
rosterWorkbookPath: "/imports/roster.xlsx",
|
||||
chargeabilityWorkbookPath: "chargeability.xlsx",
|
||||
planningWorkbookPath: "planning.xlsx",
|
||||
referenceWorkbookPath: "reference.xlsx",
|
||||
costWorkbookPath: "cost.xlsx",
|
||||
rosterWorkbookPath: "roster.xlsx",
|
||||
notes: "March import",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(stageDispoImportBatch).toHaveBeenCalledWith(ctx.db, {
|
||||
chargeabilityWorkbookPath: "/imports/chargeability.xlsx",
|
||||
planningWorkbookPath: "/imports/planning.xlsx",
|
||||
referenceWorkbookPath: "/imports/reference.xlsx",
|
||||
costWorkbookPath: "/imports/cost.xlsx",
|
||||
rosterWorkbookPath: "/imports/roster.xlsx",
|
||||
chargeabilityWorkbookPath: "chargeability.xlsx",
|
||||
planningWorkbookPath: "planning.xlsx",
|
||||
referenceWorkbookPath: "reference.xlsx",
|
||||
costWorkbookPath: "cost.xlsx",
|
||||
rosterWorkbookPath: "roster.xlsx",
|
||||
notes: "March import",
|
||||
});
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
@@ -92,18 +92,18 @@ describe("assistant dispo import batch delegation tools", () => {
|
||||
const result = await executeTool(
|
||||
"validate_dispo_import_batch",
|
||||
JSON.stringify({
|
||||
chargeabilityWorkbookPath: "/imports/chargeability.xlsx",
|
||||
planningWorkbookPath: "/imports/planning.xlsx",
|
||||
referenceWorkbookPath: "/imports/reference.xlsx",
|
||||
chargeabilityWorkbookPath: "chargeability.xlsx",
|
||||
planningWorkbookPath: "planning.xlsx",
|
||||
referenceWorkbookPath: "reference.xlsx",
|
||||
importBatchId: "batch_1",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(assessDispoImportReadiness).toHaveBeenCalledWith({
|
||||
chargeabilityWorkbookPath: "/imports/chargeability.xlsx",
|
||||
planningWorkbookPath: "/imports/planning.xlsx",
|
||||
referenceWorkbookPath: "/imports/reference.xlsx",
|
||||
chargeabilityWorkbookPath: "chargeability.xlsx",
|
||||
planningWorkbookPath: "planning.xlsx",
|
||||
referenceWorkbookPath: "reference.xlsx",
|
||||
importBatchId: "batch_1",
|
||||
});
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
|
||||
@@ -41,7 +41,7 @@ vi.mock("../ai-client.js", async (importOriginal) => {
|
||||
createDalleClient: vi.fn(() => ({
|
||||
images: {
|
||||
generate: vi.fn().mockResolvedValue({
|
||||
data: [{ b64_json: "ZmFrZQ==" }],
|
||||
data: [{ b64_json: "iVBORw0KGgoAAAAASUVORK5CYII=" }],
|
||||
}),
|
||||
},
|
||||
})),
|
||||
@@ -49,10 +49,7 @@ vi.mock("../ai-client.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
createToolContext,
|
||||
executeTool,
|
||||
} from "./assistant-tools-project-media-test-helpers.js";
|
||||
import { createToolContext, executeTool } from "./assistant-tools-project-media-test-helpers.js";
|
||||
|
||||
describe("assistant project cover generation tools", () => {
|
||||
beforeEach(() => {
|
||||
@@ -60,7 +57,8 @@ describe("assistant project cover generation tools", () => {
|
||||
});
|
||||
|
||||
it("routes project cover generation through the real project router path", async () => {
|
||||
const projectFindUnique = vi.fn()
|
||||
const projectFindUnique = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: "project_1",
|
||||
name: "Project One",
|
||||
@@ -84,7 +82,7 @@ describe("assistant project cover generation tools", () => {
|
||||
});
|
||||
const projectUpdate = vi.fn().mockResolvedValue({
|
||||
id: "project_1",
|
||||
coverImageUrl: "data:image/png;base64,ZmFrZQ==",
|
||||
coverImageUrl: "data:image/png;base64,iVBORw0KGgoAAAAASUVORK5CYII=",
|
||||
});
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
@@ -119,7 +117,7 @@ describe("assistant project cover generation tools", () => {
|
||||
|
||||
expect(projectUpdate).toHaveBeenCalledWith({
|
||||
where: { id: "project_1" },
|
||||
data: { coverImageUrl: "data:image/png;base64,ZmFrZQ==" },
|
||||
data: { coverImageUrl: "data:image/png;base64,iVBORw0KGgoAAAAASUVORK5CYII=" },
|
||||
});
|
||||
expect(projectFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: "project_1" },
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateImageDataUrl } from "../lib/image-validation.js";
|
||||
|
||||
const PNG_HEADER = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
const PNG_IEND = [0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82];
|
||||
const JPEG_HEADER = [0xff, 0xd8, 0xff, 0xe0];
|
||||
const JPEG_EOI = [0xff, 0xd9];
|
||||
|
||||
function dataUrl(mime: string, bytes: number[]): string {
|
||||
const base64 = Buffer.from(Uint8Array.from(bytes)).toString("base64");
|
||||
return `data:${mime};base64,${base64}`;
|
||||
}
|
||||
|
||||
describe("validateImageDataUrl", () => {
|
||||
it("accepts a minimal well-formed PNG", () => {
|
||||
const bytes = [...PNG_HEADER, 0x00, 0x00, 0x00, 0x00, ...PNG_IEND];
|
||||
expect(validateImageDataUrl(dataUrl("image/png", bytes))).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it("accepts a minimal well-formed JPEG", () => {
|
||||
const bytes = [...JPEG_HEADER, 0x00, 0x00, ...JPEG_EOI];
|
||||
expect(validateImageDataUrl(dataUrl("image/jpeg", bytes))).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it("rejects SVG uploads explicitly", () => {
|
||||
const svgBytes = Buffer.from("<svg xmlns='http://www.w3.org/2000/svg'/>", "utf8");
|
||||
const base64 = svgBytes.toString("base64");
|
||||
const result = validateImageDataUrl(`data:image/svg+xml;base64,${base64}`);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) expect(result.reason).toMatch(/SVG/i);
|
||||
});
|
||||
|
||||
it("rejects a polyglot PNG with an HTML tail after IEND", () => {
|
||||
const html = Buffer.from("<!doctype html><script>alert(1)</script>", "utf8");
|
||||
const bytes = [...PNG_HEADER, 0x00, 0x00, 0x00, 0x00, ...PNG_IEND, ...Array.from(html)];
|
||||
const result = validateImageDataUrl(dataUrl("image/png", bytes));
|
||||
expect(result.valid).toBe(false);
|
||||
// Either the IEND-trailer check or the polyglot scan is acceptable — both
|
||||
// reject the payload before it reaches storage. A tail after IEND naturally
|
||||
// fails the trailer check first.
|
||||
if (!result.valid) expect(result.reason).toMatch(/IEND|polyglot/i);
|
||||
});
|
||||
|
||||
it("rejects a PNG that does not end with IEND", () => {
|
||||
// Declare PNG and include header but truncate before IEND
|
||||
const bytes = [...PNG_HEADER, 0x00, 0x00, 0x00, 0x00];
|
||||
const result = validateImageDataUrl(dataUrl("image/png", bytes));
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) expect(result.reason).toMatch(/IEND/);
|
||||
});
|
||||
|
||||
it("rejects a JPEG that does not end with the EOI marker", () => {
|
||||
const bytes = [...JPEG_HEADER, 0x00, 0x00];
|
||||
const result = validateImageDataUrl(dataUrl("image/jpeg", bytes));
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) expect(result.reason).toMatch(/EOI/);
|
||||
});
|
||||
|
||||
it("rejects a MIME/content mismatch", () => {
|
||||
const bytes = [...PNG_HEADER, 0x00, ...PNG_IEND];
|
||||
const result = validateImageDataUrl(dataUrl("image/jpeg", bytes));
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) expect(result.reason).toMatch(/mismatch/i);
|
||||
});
|
||||
|
||||
it("rejects a javascript: URL embedded in an EXIF-like comment", () => {
|
||||
const marker = Buffer.from("javascript:alert(1)", "utf8");
|
||||
const bytes = [...JPEG_HEADER, ...Array.from(marker), ...JPEG_EOI];
|
||||
const result = validateImageDataUrl(dataUrl("image/jpeg", bytes));
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) expect(result.reason).toMatch(/polyglot/i);
|
||||
});
|
||||
|
||||
it("rejects a non-data-URL string", () => {
|
||||
expect(validateImageDataUrl("not a data url").valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty decoded buffer", () => {
|
||||
const result = validateImageDataUrl("data:image/png;base64,");
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user