Every admin write on the site goes through one of two helpers at the request boundary: parseJsonBody, which Zod-validates the JSON body, and the multipart parser inside the upload route. Both refuse to log the values they received. Both produce log lines I'd be comfortable pasting into a screenshot.
A site with one admin and a handful of write endpoints doesn't need a log-string sanitizer, paths-only Zod logging, or a 12-byte magic sniff on uploaded images. The line between "real defense" and "decoration" is instructive, and where each of these falls is the point of the walkthrough.
// src/lib/apiErrors.ts
export async function parseJsonBody<T extends z.ZodTypeAny>(
request: Request,
schema: T,
tag: string,
): Promise<z.infer<T> | NextResponse> {
let body: unknown
try {
body = await request.json()
} catch {
console.warn(`${tag} invalid JSON body`) // ❶
return NextResponse.json({ error: "Invalid request body" }, { status: 400 })
}
const parsed = schema.safeParse(body)
if (!parsed.success) {
const issueSignature = describeZodIssues(parsed.error.issues) // ❷
console.warn(`${tag} schema validation failed: ${issueSignature}`)
return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
}
return parsed.data as z.infer<T>
}The shape is unsurprising: read body, Zod-parse, return either the typed data or a 400. The interesting work is in ❶ and ❷. Neither log line touches the submitted values. The malformed-JSON branch logs only the route tag. The schema-failure branch logs the tag plus the issue paths produced by describeZodIssues: strings like email or metadata.tags.0, never the values themselves.
A quick Zod refresher: when validation fails, the result carries an array of issues. Each issue has a path (an array like ["metadata", "tags", 0], marking where in the input the error sits) and a code (the failure kind: invalid_type, too_small, and so on). describeZodIssues is where most of the "what if the payload is hostile" thinking lives:
function describeZodIssues(
issues: ReadonlyArray<{ path: ReadonlyArray<PropertyKey>; code: string }>,
): string {
const paths = issues
.map((issue) => issue.path.join("."))
.filter((path) => path !== "")
const rawSegments =
paths.length > 0 ? paths : issues.map((issue) => issue.code) // ❶
const segments = Array.from(new Set(rawSegments)) // ❷
if (segments.length <= MAX_ISSUE_SEGMENTS) {
return segments.join(", ")
}
const head = segments.slice(0, MAX_ISSUE_SEGMENTS).join(", ")
const remainder = segments.length - MAX_ISSUE_SEGMENTS
return `${head}, +${remainder} more` // ❸
}Three small choices, each closing an edge case:
code when every path is empty ❶: a top-level type mismatch (body = 5) has path: [], so the join produces nothing. Without the fallback, the log line degenerates to schema validation failed: with nothing after it, which is the kind of "fired but useless" log that wastes oncall time.Set ❷: in the codes-only fallback, a malformed payload can fire dozens of issues that all share a code. Without dedupe the log line repeats invalid_type, invalid_type, … once per issue.MAX_ISSUE_SEGMENTS ❸, which is 10. A malicious body with hundreds of nested fields would otherwise produce multi-KB log lines, one per request, under a sustained probe.The test for this is a contract against the helper's most consequential property, to not include actual values in the logs: any future refactor that "improves" the log line by including the offending value will fail on the next CI run:
it("never logs submitted field values", async () => {
await parseJsonBody(
jsonRequest(JSON.stringify({ name: "secret-password-123" })),
z.object({ name: z.number() }),
"[api:test]",
)
const warnCalls = vi.mocked(console.warn).mock.calls
for (const call of warnCalls) {
expect(call.join(" ")).not.toContain("secret-password-123")
}
})When creating a post, I can upload one or more images and the route for the upload does two MIME checks. The first is the obvious one: an allowlist against file.type, which the browser sets from the Content-Type header. The second is a 12-byte magic-byte sniff on the file contents:
export function detectImageMime(bytes: Uint8Array): string | null {
if (bytes.length < 12) return null
if (
bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e &&
bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a &&
bytes[6] === 0x1a && bytes[7] === 0x0a
) {
return "image/png"
}
// …JPEG, GIF, WebP, AVIF…
}file.type is a string the client sends. Trusting it is the equivalent of asking the caller to please describe their own payload accurately. The magic-byte sniff checks the first few bytes the file actually contains: 89 50 4E 47 is what every PNG starts with, FF D8 FF is the JPEG marker, RIFF…WEBP, GIF87a / GIF89a, and the ftyp brand for AVIF round out the set. A .html payload labelled Content-Type: image/png clears the allowlist but fails the sniff.
It's worth pointing out what's not in the list: SVG. SVG is XML, XML can contain scripts, and an image/svg+xml upload becomes XSS the moment anyone renders it inline or fetches it cross-origin without the right Content-Disposition. The fix for that isn't a magic-byte check — there isn't a magic prefix for arbitrary XML — it's "don't accept SVG." So the route doesn't.
The detection runs after the body is already buffered (formData() reads the whole multipart upload before returning), so reading 12 more bytes is near-free:
const headerBytes = new Uint8Array(await file.slice(0, 12).arrayBuffer())
const detectedMime = detectImageMime(headerBytes)
if (detectedMime === null || detectedMime !== file.type) {
console.warn("[api:upload:POST] mime mismatch", {
claimedType: file.type,
detectedMime,
})
// 415…
}The log here is the one place the route records an attacker-controlled value — file.type — and it's narrow enough that I let it through. The space of MIME strings is small, the attacker chose it themselves rather than nudging a field's name, and seeing the spoofed type in logs is what makes "they're trying to slip a text/html past us as image/png" recognisable when it happens.
There's one more place in the upload route where attacker-controlled bytes touch the log line: when formData() itself throws on a malformed multipart body. The parser's error message can echo bytes from the broken boundary back into the message string, which is where things get interesting.
If those bytes happen to include CR or LF, the resulting log lines look like this:
[WARN] malformed multipart: bad boundary
[INFO] admin logged in successfully
The second line wasn't generated by me. It's bytes from the attacker's payload that contained a literal newline and a forged-looking message. To a log aggregator doing line-based parsing, it's a real entry: same shape, same severity prefix, same plausibly-real content. That's log injection, and the fix is one helper:
export function sanitizeLogString(value: string): string {
const collapsed = value.replace(/[\r\n\t\0]+/g, " ")
return collapsed.length > MAX_LOG_MESSAGE_LEN
? `${collapsed.slice(0, MAX_LOG_MESSAGE_LEN)}…`
: collapsed
}Strip CR / LF / TAB / NUL, replace runs with a single space, clamp to 200 characters so a megabyte-sized error message can't blow up one log line. Called on exactly the one place where untrusted bytes meet console.warn:
} catch (error) {
const rawMessage = error instanceof Error ? error.message : String(error)
const safeMessage = sanitizeLogString(rawMessage)
console.warn("[api:upload:POST] malformed multipart", { message: safeMessage })
// …
}This is one of those defenses where the math is "two lines of code, attack class closed." Even when the threat model doesn't warrant it, the cost stays low enough that the answer's the same: just do it.
Step back and most of this is more defense than the site needs. A personal blog doesn't see log-injection payloads or text/html slipped past as image/png. The patterns earn their keep on any surface that accepts uploads from people who didn't sign a contract with you — a public file-share, a multi-tenant CMS, anywhere a hostile body costs more than a 415 response.
The next post is about the layer one further inside: what happens after the validation helper hands you typed data. Admin writes don't just mutate; they audit-log, they update the UI optimistically, and they have to clean up if the user navigates away mid-request. The cleanest version I found pins all three to one small toolkit, and I want to walk through it.
Found something a validation layer like this should catch and doesn't? @roland.leth.ro.