Skip to content
academia.sh

Lesson 05 / 17

File Upload Security

Counting the upload flow as a layer of checks: ten upload items pass through five check layers alone and layer by layer, measuring which check alone is not enough and how many unexpected items pass when one is missing, counting the points that a directly addressable storage location opens, and comparing the manual check count against the upload point count as it grows.

Published Updated

In this lesson

  • Model upload items and measure how five check layers cover them alone and together
  • Derive the number of unexpected items that pass through when each layer is missing
  • Compare the points opened by the storage location and storage name across three storage forms
Contents

The previous lesson measured how much a template body coming from outside grows the surface. There, what came in was at least text, and the names inside it could be counted. In the upload flow, what comes in is a byte sequence, and everything said about it — its type, its name, its size — is the client’s claim.

This lesson counts the upload flow as a layer of checks. What is measured is not whether a single file is malicious, but how many layers it passes through: which check alone is not enough, how many items pass through when one is missing, and how many points the choice of storage location opens.

Upload Items

The field application uploads a measurement photo and a printout document at work-order closure. The item set is fictional; each item’s head bytes are real, and the rest is a modeled size.

IO11 (assumption): each item’s expected decision is manually labeled and correct. The counts are relative to this label set; they say nothing about an upload form outside the set.

// item.mjs — upload items from the field application (fictional model)
// Each item's head bytes are real; the rest of the item is a modeled size.
export const SIGNATURE = { "image-png": [0x89, 0x50, 0x4e, 0x47], "image-jpeg": [0xff, 0xd8, 0xff],
  "document-pdf": [0x25, 0x50, 0x44, 0x46], "compressed": [0x50, 0x4b, 0x03, 0x04] };
const headBytes = (t) => Uint8Array.from(SIGNATURE[t] ?? [0x6f, 0x6b, 0x0a]);
export const findType = (b) =>
  Object.entries(SIGNATURE).find(([, s]) => s.every((x, i) => b[i] === x))?.[0] ?? "unknown";

// [name (given by the client), declared type, actual content type, size, expected decision]
export const ITEM = [
  ["measurement-north.png", "image-png", "image-png", 120_000, "accept"],
  ["measurement-south.jpg", "image-jpeg", "image-jpeg", 240_000, "accept"],
  ["printout-2026-03.pdf", "document-pdf", "document-pdf", 380_000, "accept"],
  ["meter.png", "image-png", "image-jpeg", 90_000, "reject"],
  ["archive.png", "image-png", "compressed", 150_000, "reject"],
  ["panorama.png", "image-png", "image-png", 12_000_000, "reject"],
  ["note.txt", "text-plain", "unknown", 2_000, "reject"],
  ["field/measurement.png", "image-png", "image-png", 110_000, "reject"],
  ["measurement-north.png", "image-png", "image-png", 130_000, "reject"],
  ["cover.jpeg", "image-png", "image-jpeg", 95_000, "reject"],
].map(([name, declared, actual, size, expected], i) =>
  ({ no: i + 1, name, declared, size, expected, data: headBytes(actual) }));

export const ALLOWED = ["image-png", "image-jpeg", "document-pdf"];
export const EXTENSION = { png: "image-png", jpg: "image-jpeg", jpeg: "image-jpeg", pdf: "document-pdf" };
export const MAX_SIZE = 5_000_000;

Seven items were labeled unexpected, and none carries malicious content. One photo is named with the wrong extension, one compressed file’s name carries an image extension, one panorama exceeds the limit, one name contains a path separator, and one name collides with a file already uploaded. These are the ordinary accidents of field use; the measurement counts them too.

Five Check Layers

Each layer asks a single question and looks at a single thing. The declared type looks at the type the client reports, the extension looks at the end of the file name, the signature looks at the actual bytes, the size looks at the limit, and the name looks at how the storage name is determined.

// checks.mjs — five check layers. Each layer asks a single question.
import { ITEM, ALLOWED, EXTENSION, MAX_SIZE, findType } from "./item.mjs";

export const LAYER = {
  declared:  (k) => ALLOWED.includes(k.declared),
  extension: (k) => EXTENSION[k.name.split(".").pop().toLowerCase()] === k.declared,
  signature: (k) => findType(k.data) === k.declared,
  size:      (k) => k.size <= MAX_SIZE,
  name:      (k, seen) => !k.name.includes("/") && !seen.has(k.name),
};

// Applies a check set to all items.
export function run(layerNames) {
  const seen = new Set(), acceptedItems = [];
  let unexpectedPassed = 0, legitimateBlocked = 0;
  for (const k of ITEM) {
    const passed = layerNames.every((a) => LAYER[a](k, seen));
    if (passed) { acceptedItems.push(k); seen.add(k.name); }
    if (passed && k.expected === "reject") unexpectedPassed++;
    if (!passed && k.expected === "accept") legitimateBlocked++;
  }
  return { accepted: acceptedItems.length, acceptedItems, unexpectedPassed, legitimateBlocked };
}
// coverage.mjs — each layer alone, then layer by layer
import { ITEM } from "./item.mjs";
import { LAYER, run } from "./checks.mjs";

const LAYERS = Object.keys(LAYER);
const unexpected = ITEM.filter((k) => k.expected === "reject").length;
console.log(`${ITEM.length} upload items: ${ITEM.length - unexpected} expected, ${unexpected} unexpected\n`);

console.log(`${"layer alone".padEnd(13)}${"accepted".padStart(10)}${"unexpected passed".padStart(19)}${"legitimate blocked".padStart(20)}`);
for (const a of LAYERS) {
  const r = run([a]);
  console.log(a.padEnd(13) + String(r.accepted).padStart(10) + String(r.unexpectedPassed).padStart(19) +
    String(r.legitimateBlocked).padStart(20));
}

console.log(`\n${"layer by layer".padEnd(48)}${"accepted".padStart(10)}${"unexpected passed".padStart(19)}${"legitimate blocked".padStart(20)}`);
for (let i = 1; i <= LAYERS.length; i++) {
  const set = LAYERS.slice(0, i), r = run(set);
  console.log(set.join(" + ").padEnd(48) + String(r.accepted).padStart(10) +
    String(r.unexpectedPassed).padStart(19) + String(r.legitimateBlocked).padStart(20));
}
10 upload items: 3 expected, 7 unexpected

layer alone    accepted  unexpected passed  legitimate blocked
declared              9                  6                   0
extension             8                  5                   0
signature             6                  3                   0
size                  9                  6                   0
name                  8                  5                   0

layer by layer                                    accepted  unexpected passed  legitimate blocked
declared                                                 9                  6                   0
declared + extension                                     8                  5                   0
declared + extension + signature                         6                  3                   0
declared + extension + signature + size                  5                  2                   0
declared + extension + signature + size + name           3                  0                   0

The top table shows that no layer alone is enough. The strongest layer is the signature, and even it stops only four of the seven unexpected items and lets three through. The declared type and the size each let six items through. This is the number behind the sentence “type checking alone is not enough”: the declared type alone does not look at the content even once, in six of the items it lets through.

The bottom table shows how the layers complete each other. With all five layers, the unexpected items passing through drop to zero, and the legitimate-blocked column stays at zero throughout — in this set, the layers do not block legitimate uploads. The cost coming out at zero is because the set is narrow, and IO11 says so.

The Missing Layer and the Storage Location

Which layer actually carries the load is seen by removing it. The same block also measures the storage location: where the accepted items are written and whether that location is directly addressable.

IO12 (assumption): every accepted item that is directly addressable is counted as a point. What is measured is not the outcome the point opens, but its count.

// storage.mjs — the cost of a missing layer and the points the storage location opens
import { ITEM } from "./item.mjs";
import { LAYER, run } from "./checks.mjs";

const LAYERS = Object.keys(LAYER), FULL = run(LAYERS);
console.log(`${"missing layer".padEnd(15)}${"accepted".padStart(10)}${"unexpected passed".padStart(19)}`);
for (const a of LAYERS) {
  const r = run(LAYERS.filter((x) => x !== a));
  console.log(a.padEnd(15) + String(r.accepted).padStart(10) + String(r.unexpectedPassed).padStart(19));
}
console.log(`${"none missing".padEnd(15)}${String(FULL.accepted).padStart(10)}${String(FULL.unexpectedPassed).padStart(19)}`);

// Storage location: same accepted set, three storage forms. The storage name is derived from the item.
const generatedName = (k) => `y-${String(k.no).padStart(4, "0")}.bin`;
const STORAGE = [["served directory + client name", true, (k) => k.name],
  ["served directory + generated name", true, generatedName],
  ["unserved store + intermediary endpoint", false, generatedName]];
console.log(`\n${"storage form".padEnd(40)}${"addressable".padStart(13)}${"with client name".padStart(18)}${"authorization point".padStart(21)}`);
for (const [name, served, nameFn] of STORAGE) {
  const addressable = served ? FULL.acceptedItems.length : 0;
  const withClientName = served ? FULL.acceptedItems.filter((k) => nameFn(k) === k.name).length : 0;
  console.log(name.padEnd(40) + String(addressable).padStart(13) +
    String(withClientName).padStart(18) + String(served ? 0 : 1).padStart(21));
}
missing layer    accepted  unexpected passed
declared                3                  0
extension               3                  0
signature               5                  2
size                    4                  1
name                    5                  2
none missing            3                  0

storage form                              addressable  with client name  authorization point
served directory + client name                      3                 3                    0
served directory + generated name                   3                 0                    0
unserved store + intermediary endpoint              0                 0                    1

The top table separates the layers by weight. When the declared type and extension are removed, the unexpected items passing through are still zero: whatever these two layers stop, the signature also stops. When the signature, name, and size are removed, two, two, and one item pass through, respectively. Three of the five layers carry the load, two are redundant. Being redundant does not mean they are unnecessary — they are cheap and reject earlier — but they are not what provides the coverage.

The signature being load-bearing does not mean it is sufficient. The signature looks only at the first few bytes and says nothing about the rest of the file; the only thing it measures is whether the declared type is consistent with the content. This is the boundary of that measurement’s scope, and it needs to be written down: the number that hits zero in the table is the inconsistency between the declared type and the content, not that the file is safe.

The middle table measures the storage decision. All three items are accepted; the only thing that changes is where they are written. When written to a served directory, all three open three directly addressable points. If the name comes from the client, all three of those three addresses are addresses the uploader knows. The server-generated name brings this count down to zero but does not change the point count. In the unserved store and intermediary endpoint setup, the addressable point is zero, but in exchange one authorization check point is born — the file is now handed out through an endpoint, and at that endpoint, who can obtain what has to be asked.

The storage decision being independent of the acceptance decision is this measurement’s main finding. Even if all five layers work correctly and the unexpected items passing through are zero, the storage location still opens three points. The two decisions are made in two separate places, and one does not close the other; the coverage of the upload flow is known only when both are counted together. A review that counts the check layers but not the storage location has left out half the count.

Upload Point Inventory

Everything measured so far was on a single upload point. The regional measurement network has five upload points, and each has its layers written separately.

// points.mjs — upload point inventory: which layer is manually written at which point
import { ITEM } from "./item.mjs";
import { LAYER, run } from "./checks.mjs";

const LAYERS = Object.keys(LAYER);
// [service, upload point, layers manually written at that point]
const POINT = [
  ["field", "work-order attachment", ["declared", "extension", "signature", "size", "name"]],
  ["field", "photo", ["declared", "extension", "signature"]],
  ["subscriber portal", "document", ["declared", "size"]],
  ["subscriber portal", "reading printout", ["declared", "extension", "size", "name"]],
  ["invoice", "dispute attachment", ["declared"]],
];

console.log(`${POINT.length} upload points, ${LAYERS.length} layers, ${ITEM.length} items\n`);
console.log(`${"upload point".padEnd(23)}${"layers written".padStart(16)}${"missing".padStart(9)}${"unexpected passed".padStart(19)}`);
let manual = 0, passed = 0, full = 0;
for (const [, n, k] of POINT) {
  const r = run(k);
  manual += k.length; passed += r.unexpectedPassed; full += k.length === LAYERS.length ? 1 : 0;
  console.log(n.padEnd(23) + String(k.length).padStart(16) + String(LAYERS.length - k.length).padStart(9) +
    String(r.unexpectedPassed).padStart(19));
}
console.log(`\n${"setup".padEnd(26)}${"checks written".padStart(16)}${"fully covered points".padStart(22)}${"unexpected passed".padStart(19)}`);
console.log("manual per-point writing".padEnd(26) + String(manual).padStart(16) +
  String(full).padStart(22) + String(passed).padStart(19));
const central = run(LAYERS).unexpectedPassed * POINT.length;
console.log("central upload pattern".padEnd(26) + String(LAYERS.length).padStart(16) +
  String(POINT.length).padStart(22) + String(central).padStart(19));
console.log(`\nchecks to write when a new upload point is added: manual ${LAYERS.length}, central pattern 0`);
5 upload points, 5 layers, 10 items

upload point             layers written  missing  unexpected passed
work-order attachment                 5        0                  0
photo                                 3        2                  3
document                              2        3                  5
reading printout                      4        1                  2
dispute attachment                    1        4                  6

setup                       checks written  fully covered points  unexpected passed
manual per-point writing                15                     1                 16
central upload pattern                   5                     5                  0

checks to write when a new upload point is added: manual 5, central pattern 0

Only one of the five points carries all five layers. The remaining four have a combined ten missing layers, and the same set of ten items passing through these points produces sixteen unexpected acceptances. The weakest point, the dispute attachment, carries a single layer and accepts six of the seven unexpected items.

The difference between the two setups also shows up in the number of checks written. In manual per-point writing, fifteen checks are written and one point is fully covered; in the central pattern, five checks are written in a single place and all five of the five points are covered. The same coverage is achieved with three times less code, and the number of checks to write when a new upload point is added stays at zero.

Summary

  • Seven of the ten upload items were labeled unexpected, and none carries malicious content; there is a wrong extension, a wrong declared type, a limit overrun, a name with a path separator, and a name collision.
  • No layer alone was enough: the strongest, the signature, stopped 4 of 7 unexpected items and let 3 through, while the declared type and size each let 6 items through. The five layers together brought the unexpected items passing through down to 0, and no legitimate item was blocked at any stage.
  • The layers do not carry equal weight: with the declared type and extension removed, the items passing through stay at 0; with the signature and name removed, 2 pass each; with the size removed, 1 passes. Three layers carry the load, two are redundant.
  • The storage location is a point independent of the acceptance decision: in the served directory, 3 accepted items open 3 addressable points. The server-generated name brings the guessable address down to 0; the unserved store brings the addressable point down to 0 and adds 1 authorization check point.
  • Only 1 of the five upload points carries all five layers; the remaining four have 10 missing layers, and the same item set produces 16 unexpected acceptances. Manual per-point writing has 15 checks written, the central pattern covers all five points with 5 checks, and the checks to write for a new point are 0.

Next Step

In the upload flow, what came from outside was a byte sequence, and the server only stored it. In some flows the server goes further: it makes a request itself to an address that comes from outside. The report generator fetches a document from the link the subscriber portal provides, and the field application reaches an external endpoint for a map tile. The party making those requests is the server, and the server’s place on the network is different from the client’s. The next lesson passes outbound request targets through a set of constraints and measures a single question: how many targets can reach the internal network, and at what point does applying the constraint bring the coverage down.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close