---
title: "Input validation and logs that don't leak"
slug: input-validation-and-logs-that-dont-leak
section: tech
date: 2026-06-12T07:28:00.000Z
canonical: https://roland.leth.ro/blog/tech/input-validation-and-logs-that-dont-leak
---

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.

## parseJsonBody: typed in, paths out

```typescript
// 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:

```typescript
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:

1. Falling back to `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.
2. De-duplicating via `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.
3. Capping at `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:

```typescript
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")
  }
})
```

## Magic-byte MIME sniff for uploads

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:

```typescript
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:

```typescript
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.

## sanitizeLogString, and the kind of bug it stops

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:

```typescript
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`:

```typescript
} 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.

## What's next

[The next post][next] 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][].

[prev]: /blog/tech/auth-for-a-single-user-site-without-nextauth "Auth for a single-user site, without NextAuth"
[next]: /blog/tech/the-admin-write-contract "The admin write contract"
[@roland.leth.ro]: https://bsky.app/profile/roland.leth.ro "@roland.leth.ro on Bluesky"
