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:
@@ -1,8 +1,5 @@
|
||||
import {
|
||||
DispoStagedRecordType,
|
||||
ImportBatchStatus,
|
||||
StagedRecordStatus,
|
||||
} from "@capakraken/db";
|
||||
import path from "node:path";
|
||||
import { DispoStagedRecordType, ImportBatchStatus, StagedRecordStatus } from "@capakraken/db";
|
||||
import {
|
||||
assessDispoImportReadiness,
|
||||
stageDispoImportBatch as stageDispoImportBatchApplication,
|
||||
@@ -34,12 +31,24 @@ const paginationSchema = z.object({
|
||||
const importBatchStatusSchema = z.nativeEnum(ImportBatchStatus);
|
||||
const stagedRecordStatusSchema = z.nativeEnum(StagedRecordStatus);
|
||||
const stagedRecordTypeSchema = z.nativeEnum(DispoStagedRecordType);
|
||||
// Reject absolute paths and paths that contain `..` segments at the router
|
||||
// boundary. The workbook reader re-validates against DISPO_IMPORT_DIR as
|
||||
// defence-in-depth, but rejecting early here gives a clearer error to admin
|
||||
// users and shrinks the attack surface if the reader is ever called with a
|
||||
// different allowlist policy.
|
||||
const workbookPathSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Workbook path is required.")
|
||||
.max(4096, "Workbook path is too long.")
|
||||
.refine((value) => value.toLowerCase().endsWith(".xlsx"), {
|
||||
message: "Only .xlsx workbook paths are supported.",
|
||||
})
|
||||
.refine((value) => !path.isAbsolute(value), {
|
||||
message: "Workbook path must be relative to the configured import directory.",
|
||||
})
|
||||
.refine((value) => !value.split(/[\\/]/).some((segment) => segment === ".."), {
|
||||
message: "Workbook path must not contain parent-directory segments.",
|
||||
});
|
||||
|
||||
export const stageImportBatchInputSchema = z.object({
|
||||
@@ -120,17 +129,16 @@ type ListStagedUnresolvedRecordsInput = z.infer<typeof listStagedUnresolvedRecor
|
||||
type ResolveStagedRecordInput = z.infer<typeof resolveStagedRecordInputSchema>;
|
||||
type CommitImportBatchInput = z.infer<typeof commitImportBatchInputSchema>;
|
||||
|
||||
export async function stageImportBatch(
|
||||
ctx: DispoProcedureContext,
|
||||
input: StageImportBatchInput,
|
||||
) {
|
||||
export async function stageImportBatch(ctx: DispoProcedureContext, input: StageImportBatchInput) {
|
||||
return stageDispoImportBatchApplication(ctx.db, {
|
||||
chargeabilityWorkbookPath: input.chargeabilityWorkbookPath,
|
||||
planningWorkbookPath: input.planningWorkbookPath,
|
||||
referenceWorkbookPath: input.referenceWorkbookPath,
|
||||
...(input.costWorkbookPath !== undefined ? { costWorkbookPath: input.costWorkbookPath } : {}),
|
||||
...(input.notes !== undefined ? { notes: input.notes } : {}),
|
||||
...(input.rosterWorkbookPath !== undefined ? { rosterWorkbookPath: input.rosterWorkbookPath } : {}),
|
||||
...(input.rosterWorkbookPath !== undefined
|
||||
? { rosterWorkbookPath: input.rosterWorkbookPath }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,7 +150,9 @@ export async function validateImportBatch(input: ValidateImportBatchInput) {
|
||||
...(input.costWorkbookPath !== undefined ? { costWorkbookPath: input.costWorkbookPath } : {}),
|
||||
...(input.importBatchId !== undefined ? { importBatchId: input.importBatchId } : {}),
|
||||
...(input.notes !== undefined ? { notes: input.notes } : {}),
|
||||
...(input.rosterWorkbookPath !== undefined ? { rosterWorkbookPath: input.rosterWorkbookPath } : {}),
|
||||
...(input.rosterWorkbookPath !== undefined
|
||||
? { rosterWorkbookPath: input.rosterWorkbookPath }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,10 +210,7 @@ export async function resolveStagedRecord(
|
||||
return resolveStagedRecordMutation(ctx.db, input);
|
||||
}
|
||||
|
||||
export async function commitImportBatch(
|
||||
ctx: DispoProcedureContext,
|
||||
input: CommitImportBatchInput,
|
||||
) {
|
||||
export async function commitImportBatch(ctx: DispoProcedureContext, input: CommitImportBatchInput) {
|
||||
return commitImportBatchMutation(ctx.db, {
|
||||
importBatchId: input.importBatchId,
|
||||
allowTbdUnresolved: input.allowTbdUnresolved,
|
||||
|
||||
@@ -100,6 +100,18 @@ export const projectCoverProcedures = {
|
||||
message: `Gemini error: ${parseGeminiError(err)}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Provider-generated output is still untrusted — a compromised or
|
||||
// misconfigured upstream could return a polyglot payload. Run the
|
||||
// same magic-byte + trailer + marker check we apply to user uploads
|
||||
// before we persist the data URL to the database.
|
||||
const providerCheck = validateImageDataUrl(coverImageUrl);
|
||||
if (!providerCheck.valid) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Provider image rejected by validator: ${providerCheck.reason}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const dalleClient = createDalleClient(runtimeSettings);
|
||||
const model =
|
||||
@@ -135,6 +147,14 @@ export const projectCoverProcedures = {
|
||||
}
|
||||
|
||||
coverImageUrl = `data:image/png;base64,${b64}`;
|
||||
|
||||
const providerCheck = validateImageDataUrl(coverImageUrl);
|
||||
if (!providerCheck.valid) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Provider image rejected by validator: ${providerCheck.reason}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.project.update({
|
||||
|
||||
Reference in New Issue
Block a user