FLOWLOGIC
module /files-and-documents

unit U44 of 5

Accepting uploads safely

size, magic numbers, your own storage key

The moment your automation accepts a file from outside, it is running an upload endpoint, with all the obligations that carries. Someone will send a 900 MB file, a file whose extension says `.csv` and whose contents are an executable, and a file named `../../etc/passwd`. None of these are exotic; they are what an open endpoint receives within days of existing.

Check three things before the bytes go anywhere. Size, enforced as the stream arrives rather than after you have buffered it all — a limit you apply afterwards has already cost you the memory. Type, determined from the leading bytes (the magic number) rather than from the extension or the client-supplied content-type, both of which are claims. And the filename, which should be discarded rather than sanitised: generate your own identifier and keep the original only as a display label, so no path a caller invents can ever reach a filesystem.

Where it breaks: trusting the extension. A `.csv` that is actually a 200 MB zip, or a script, is a one-line check away from being caught and a serious incident away from not being. Where the file is large or the flow is public, prefer a signed upload URL: the client uploads directly to storage, which enforces the size and type for you, and your flow receives a reference instead of the bytes.

worked example

Three checks before a byte is stored, and the identifier the caller does not get to choose.

const MAX_BYTES = 25 * 1024 * 1024;

// Magic numbers: the file's own claim about itself, in its first bytes.
const SIGNATURES = {
  '25504446': 'application/pdf',      // %PDF
  '504b0304': 'application/zip',      // PK.. - also .xlsx and .docx
  'ffd8ff':   'image/jpeg',
};

export const code = async (inputs) => {
  const bytes = inputs.fileBytes;

  // 1. size — the real check belongs on the stream; this is the backstop.
  if (bytes.length > MAX_BYTES) throw new Error('413 file too large');

  // 2. type — from the bytes, never from the extension or the client's header.
  const magic = bytes.subarray(0, 4).toString('hex');
  const actual = Object.entries(SIGNATURES).find(([sig]) => magic.startsWith(sig))?.[1];
  if (actual !== 'application/pdf') throw new Error('415 not a PDF, whatever it is called');

  // 3. the name is a display label, never a path. We choose the identifier.
  return {
    storageKey: `uploads/${inputs.tenantId}/${crypto.randomUUID()}.pdf`,
    displayName: String(inputs.filename ?? 'document.pdf').slice(0, 120),
  };
};

field checklist

common failure — A .csv that was a 200 MB zip

An intake endpoint accepted anything named `.csv`, buffered it, and handed it to a parser. A client's misconfigured export sent a 200 MB zip archive with a CSV extension; the worker exhausted its memory and the whole pipeline stopped for every tenant. Check the magic number rather than the extension, enforce the size limit as the stream arrives, and use a signed upload URL so storage rejects oversized files before your flow ever sees them.

check your understanding

A public endpoint accepts document uploads. Put the checks in the order they should run.

  1. Click the steps below in the order they must run.

next unit opens once this is passed

sandbox validation

The check above confirms you followed the unit. Marking the module COMPLETED takes more: build the automation in your own engine and submit the exported flow and its run evidence, signed, to your unique validation URL. See the module page for that spec.