Software Securityhome
Fix a findingSearch
Fix card · A06:2025 Insecure Design

Fix unrestricted file upload in Next.js

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Decide the type from the bytes, not from file.type or the name. In the POST handler require the extension, the declared Content-Type and the PNG or JPEG signature to agree, cap the size, write under a random name outside the web root, and serve through a GET handler that sends the verified Content-Type with nosniff and Content-Disposition: attachment.

Stack
  • Language TypeScript (Next.js App Router route handlers)
  • Runtime Node.js 26.11.1 (route handlers use the default Node.js runtime)
  • Framework Next.js 16.4.0 (App Router route handlers)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 37 KB
On this page

Steps

  1. Read the file from request.formData() and check its type against a short allowlist in which the extension, file.type (the declared Content-Type) and the file signature (first bytes) all name the same image type.
  2. Cap the size: reject anything over the feature's limit (512 KiB here) by file.size and by the length of the bytes you read.
  3. Generate the stored name yourself (random bytes plus the verified extension); never use file.name as a path or an identity, and never overwrite (open with the 'wx' flag).
  4. Store uploads outside anything Next serves as static (not public/), in a private folder, and serve them from a route handler that accepts only names you generated.
  5. In the serve handler set the verified Content-Type, X-Content-Type-Options: nosniff and Content-Disposition: attachment, or serve user files from a separate origin. Do not let the stored extension pick the Content-Type.
  6. Add regression tests that upload an HTML page, an SVG with script, HTML named avatar.png, a polyglot named avatar.png.html, an over-size file and a reused name, and assert what is stored and how it is served.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerablelib/impl/vulnerable.tsTypeScript
export async function store(request: Request): Promise<Response> {
  const file = (await request.formData()).get('avatar');
  if (!(file instanceof File)) {
    return Response.json({ error: 'attach the image in the "avatar" field' }, { status: 400 });
  }
  const bytes = Buffer.from(await file.arrayBuffer());
  // The client's file name becomes the stored name, and the uploads folder is public.
  const name = path.basename(file.name);
  await writeFile(path.join(dirs().uploads, name), bytes);
  return Response.json({ url: `/avatars/${name}` }, { status: 201 });
}

With IMPL=vulnerable, 7 of 13 tests fail (7 exploit tests).

The fix: check and store the upload

Fixedlib/impl/fixed.tsTypeScript
const MAX_BYTES = 512 * 1024;
const PNG = { mime: 'image/png', ext: '.png', signature: Buffer.from('89504e470d0a1a0a', 'hex') };
const JPEG = { mime: 'image/jpeg', ext: '.jpg', signature: Buffer.from('ffd8ff', 'hex') };
const TYPES = new Map([
  ['.png', PNG],
  ['.jpg', JPEG],
  ['.jpeg', JPEG],
]);

export async function store(request: Request): Promise<Response> {
  const file = (await request.formData()).get('avatar');
  if (!(file instanceof File)) {
    return Response.json({ error: 'attach the image in the "avatar" field' }, { status: 400 });
  }
  if (file.size > MAX_BYTES) {
    return Response.json({ error: 'avatars must be 512 KiB or smaller' }, { status: 413 });
  }
  const bytes = Buffer.from(await file.arrayBuffer());
  if (bytes.length > MAX_BYTES) {
    return Response.json({ error: 'avatars must be 512 KiB or smaller' }, { status: 413 });
  }
  // extname() returns the last extension only: '.html' for avatar.png.html.
  const type = TYPES.get(path.extname(file.name).toLowerCase());
  if (!type || file.type !== type.mime || !bytes.subarray(0, type.signature.length).equals(type.signature)) {
    return Response.json({ error: 'avatars must be PNG or JPEG images' }, { status: 415 });
  }
  // A name the server makes up, in a folder the web root does not serve; 'wx' never overwrites.
  const name = randomBytes(16).toString('hex') + type.ext;
  await writeFile(path.join(dirs().storage, name), bytes, { flag: 'wx' });
  return Response.json({ url: `/avatars/${name}` }, { status: 201 });
}

With IMPL=fixed, 13 of 13 tests pass.

The fix: serve it as a download

Fixedlib/impl/fixed.tsTypeScript
export async function serve(name: string): Promise<Response> {
  // Only names this code generated; anything else, ../ included, is simply not found.
  const match = /^([0-9a-f]{32})\.(png|jpg)$/.exec(name);
  if (!match) return Response.json({ error: 'not found' }, { status: 404 });
  const mime = match[2] === 'png' ? 'image/png' : 'image/jpeg';
  const bytes = await readFile(path.join(dirs().storage, name)).catch((err: NodeJS.ErrnoException) => {
    if (err.code === 'ENOENT') return null;
    throw err;
  });
  if (bytes === null) return Response.json({ error: 'not found' }, { status: 404 });
  return new Response(new Uint8Array(bytes), {
    headers: {
      'Content-Type': mime,
      'X-Content-Type-Options': 'nosniff',
      'Content-Disposition': `attachment; filename="${name}"`,
    },
  });
}

With IMPL=fixed, 13 of 13 tests pass.

A fix that looks safe but is not

Wrong fixlib/impl/wrong.tsTypeScript
const MAX_BYTES = 512 * 1024;
const ALLOWED_TYPES = ['image/png', 'image/jpeg'];

export async function store(request: Request): Promise<Response> {
  const file = (await request.formData()).get('avatar');
  if (!(file instanceof File)) {
    return Response.json({ error: 'attach the image in the "avatar" field' }, { status: 400 });
  }
  if (file.size > MAX_BYTES) {
    return Response.json({ error: 'avatars must be 512 KiB or smaller' }, { status: 413 });
  }
  // Trusts the declared Content-Type, which the client chooses.
  if (!ALLOWED_TYPES.includes(file.type)) {
    return Response.json({ error: 'avatars must be PNG or JPEG images' }, { status: 415 });
  }
  const bytes = Buffer.from(await file.arrayBuffer());
  // A random name, but it keeps the client's extension, so the extension still decides how the
  // file is served.
  const name = randomBytes(16).toString('hex') + path.extname(file.name).toLowerCase();
  await writeFile(path.join(dirs().uploads, name), bytes);
  return Response.json({ url: `/avatars/${name}` }, { status: 201 });
}

With IMPL=wrong, 3 of 13 tests fail (3 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/avatars.test.tsTypeScript
describe(`exploit (IMPL=${implName})`, () => {
  test('an HTML page with a script is refused', async () => {
    await assertRefused(await upload('profile.html', 'text/html', html), 415);
  });

  test('an SVG with a script is refused', async () => {
    await assertRefused(await upload('avatar.svg', 'image/svg+xml', svg), 415);
  });

  test('HTML named avatar.png and declared image/png is refused (mismatched type)', async () => {
    await assertRefused(await upload('avatar.png', 'image/png', html), 415);
  });

  test('a double extension is refused even when the bytes start like a PNG', async () => {
    await assertRefused(await upload('avatar.png.html', 'image/png', polyglot), 415);
  });

  test('a PNG that is also an HTML page is served only as an image download', async () => {
    const res = await upload('avatar.png', 'image/png', polyglot);
    expect(res.status).toBe(201);
    const served = await fetchUrl(res.url!);
    expect(served.headers.get('content-type')).toMatch(/^image\/png\b/);
    expect(served.headers.get('x-content-type-options')).toBe('nosniff');
    expect(served.headers.get('content-disposition') ?? 'inline').toMatch(/^attachment\b/);
  });

  test('../ in the file name cannot put the file outside the upload folder', async () => {
    await upload('../escaped.png', 'image/png', png);
    expect(await filesOutsideUploadFolders()).toEqual([]);
  });

  test('an absolute file name cannot put the file outside the upload folder', async () => {
    await upload(path.join(tmp, 'escaped.png'), 'image/png', png);
    expect(await filesOutsideUploadFolders()).toEqual([]);
  });

  test('a file one byte over the limit is refused with 413', async () => {
    await assertRefused(await upload('avatar.png', 'image/png', padTo(png, seed.limitBytes + 1)), 413);
  });

  test("an upload cannot replace another user's file by reusing its name", async () => {
    const alice = await upload('avatar.png', 'image/png', png);
    const mallory = await upload('avatar.png', 'image/png', otherPng);
    expect(alice.status).toBe(201);
    expect(mallory.status).toBe(201);
    const served = await fetchUrl(alice.url!);
    expect(Buffer.from(await served.arrayBuffer()), "Alice's URL must still serve her own image").toEqual(png);
  });
});

Pitfalls

  • Trusting file.type: it is the Content-Type the client wrote into the multipart part, so an HTML page can claim image/png. Checking it, keeping the client's extension, and serving by that extension is this card's wrong implementation.
  • Putting uploads under public/: Next serves public/ as static files with a Content-Type from the extension, so an uploaded .html or .svg runs as a page on your origin.
  • Letting the stored extension choose the served Content-Type. The serve handler must set the type it verified, with nosniff, so a .png whose bytes are HTML is still sent as an image download.
  • Using file.name as the path or the identity: it can carry ../ or collide with another user's file. Generate the name; open with 'wx'.
  • A signature check alone is not proof of a harmless file: a PNG with an HTML page appended is a valid image and, served as text/html, a working page. The allowlist plus the serving headers are what stop it.
  • No size cap lets a client exhaust memory and disk; enforce it on the request body, not only after the file is stored.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs file-upload/typescript-nextjs does this for you).
  2. Upload an HTML page, an SVG with script, an HTML file named avatar.png declared image/png, a PNG+HTML polyglot named avatar.png.html, a file one byte over the limit, and the same name twice. The fix refuses the disguised files, serves the accepted polyglot only as an image download with nosniff, and never writes outside the upload folder or overwrites a file.
  3. The tests assert on status, files and the served Content-Type and disposition, not on a browser. Do not rely on a scanner: Semgrep and CodeQL do not check an upload's type allowlist or its serving headers for Next.js, so the tests are the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix unrestricted file upload in Next.js
ToolRuleWhat it found here
Semgrepp/owasp-top-ten0 findings. The semgrep-rules repository has no rule for request.formData(), File uploads or a serve handler's Content-Type, so none of the three implementations is checked for what it accepts, where it stores it, or how it serves it.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0 (app lib)
CodeQLjs/http-to-file-accessTagged CWE-434. It reports request input that reaches a file write, so all three implementations' writeFile(..., bytes) would be flagged, the fixed one included; it does not distinguish a checked upload from an unchecked one. Medium precision (security-extended suite).Checked: github/codeql main; not executed

No scanner here checks the type allowlist, the storage location or the serving headers. The regression tests, which upload disguised files and read back the served Content-Type and disposition, are the control.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageTypeScript (Next.js App Router route handlers)
RuntimeNode.js 26.11.1 (route handlers use the default Node.js runtime)
FrameworkNext.js 16.4.0 (App Router route handlers)
next16.4.0
react, react-dom19.3.0
node:crypto, node:fs/promises, node:path (built in)Node.js 26.11.1
vitest (test only)5.0.3
typescript (type check and next build only)6.0.3

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the file-upload/typescript-nextjsfolder.

Shell
npm ci
Shell
# Expected: every test passes
IMPL=fixed npm test --silent

# Expected: an exploit test fails
IMPL=vulnerable npm test --silent

# Expected: a test fails
IMPL=wrong npm test --silent

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs file-upload/typescript-nextjs runs all three modes and checks the expected results.

Download the project ZIP, 37 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • node 26.11.1
  • npm 11.20.0
  • next 16.4.0
  • react 19.3.0
  • vitest 5.0.3
  • typescript 6.0.3
Matrix
fixed: 13 of 13 tests pass; vulnerable: 7 of 13 tests fail (7 exploit tests); wrong: 3 of 13 tests fail (3 exploit tests)
Test results
IMPL=fixed 13/13 pass; IMPL=vulnerable 7/13 fail (all exploit tests: HTML, SVG, HTML named avatar.png, avatar.png.html, the polyglot's serving headers, one byte over the limit, the reused name); IMPL=wrong 3/13 fail (all exploit tests: HTML named avatar.png, avatar.png.html served as a page, and the polyglot's serving headers). The ../ and absolute-name tests pass in every mode because the stored name is reduced to its base name. tsc --noEmit passes.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP File Upload Cheat Sheet
  2. OWASP Top 10:2025 A06 Insecure Design
  3. CWE-434: Unrestricted Upload of File with Dangerous Type
  4. CWE-646: Reliance on File Name or Extension of Externally-Supplied File
  5. CWE-73: External Control of File Name or Path
  6. Next.js: Route Handlers (reading form data)
  7. MDN: X-Content-Type-Options
  8. MDN: SVG as an image (scripts are disabled only in image contexts)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.