Rules Hub
Coding Rules Library
Rule priority, scope & exceptions
Use this to align rules with the senior-level structure (P0/P1/P2, scope, exceptions/tradeoffs).
Sanitize upload filenames and contain the resolved path within a base directory
Generate server-side storage names and verify the resolved absolute path stays under the intended directory; never build paths from a client-supplied filename.
Bad example
| 1 | import path from 'node:path'; |
| 2 | import fs from 'node:fs/promises'; |
| 3 |
|
| 4 | const UPLOAD_DIR = '/var/app/uploads'; |
| 5 |
|
| 6 | // originalFilename is whatever the client typed: '../../etc/cron.d/payload', |
| 7 | // '..\\..\\windows\\...', or 'x.png\0.php'. |
| 8 | const dest = path.join(UPLOAD_DIR, file.originalFilename); |
| 9 | await fs.writeFile(dest, buffer); // arbitrary write outside UPLOAD_DIR |
Explanation (EN)
`path.join` happily normalizes `../` segments, so a crafted `originalFilename` escapes `UPLOAD_DIR` and writes anywhere the process can — overwriting config, cron jobs, or app code. Using the raw filename for the stored object is the canonical path-traversal sink (OWASP A01).
Objašnjenje (HR)
`path.join` rado normalizira `../` segmente, pa izraden `originalFilename` izade iz `UPLOAD_DIR` i pise bilo gdje gdje proces moze — prepisujuci konfiguraciju, cron poslove ili kod aplikacije. Koristenje sirovog imena datoteke za spremljeni objekt je kanonski path-traversal ponor (OWASP A01).
Good example
| 1 | import path from 'node:path'; |
| 2 | import { randomUUID } from 'node:crypto'; |
| 3 | import fs from 'node:fs/promises'; |
| 4 |
|
| 5 | const UPLOAD_DIR = path.resolve('/var/app/uploads'); |
| 6 |
|
| 7 | function resolveSafe(baseDir: string, ext: string): string { |
| 8 | const name = `${randomUUID()}.${ext}`; // opaque, server-generated |
| 9 | const dest = path.resolve(baseDir, name); |
| 10 | // Defense in depth: confirm we never escaped the base directory. |
| 11 | if (dest !== path.join(baseDir, name) || !dest.startsWith(baseDir + path.sep)) { |
| 12 | throw new Error('Resolved path escaped upload directory'); |
| 13 | } |
| 14 | return dest; |
| 15 | } |
| 16 |
|
| 17 | // `safeExt` comes from the content-sniff rule, not from the client. |
| 18 | const dest = resolveSafe(UPLOAD_DIR, safeExt); |
| 19 | await fs.writeFile(dest, buffer, { flag: 'wx' }); // wx: fail if it somehow exists |
Explanation (EN)
The stored name is a server-generated UUID with a verified extension, so client input never reaches the path. The resolved absolute path is asserted to live under the canonical base directory, and `flag: 'wx'` prevents clobbering existing files. Keep the original filename only as a display label in metadata, never as a path component.
Objašnjenje (HR)
Spremljeno ime je UUID generiran na posluzitelju s provjerenom ekstenzijom, pa korisnicki unos nikad ne dospijeva u putanju. Provjerava se da rijesena apsolutna putanja ostaje unutar kanonskog osnovnog direktorija, a `flag: 'wx'` sprjecava prepisivanje postojecih datoteka. Izvorno ime datoteke cuvaj samo kao prikazni naziv u metapodacima, nikad kao dio putanje.
Notes (EN)
Use `node:path` primitives, not string concatenation. NUL-byte and Unicode-normalization tricks are why an explicit `startsWith(base + sep)` containment check beats a naive `replace('..','')` blocklist.
Bilješke (HR)
Koristi `node:path` primitive, ne spajanje stringova. Trikovi s NUL bajtom i Unicode normalizacijom razlog su zasto eksplicitna `startsWith(base + sep)` provjera zadrzavanja nadmasuje naivni `replace('..','')` blocklist.
Exceptions / Tradeoffs (EN)
The same containment check applies to any download/serve endpoint that maps a user-supplied id or name to a file (e.g. `GET /files/:name`) — resolve and assert it's under the base directory before reading. If you store the display filename, escape it at render and set `Content-Disposition: attachment; filename=...` carefully to avoid header injection.
Iznimke / Tradeoffi (HR)
Ista provjera zadrzavanja vrijedi za svaku download/serve rutu koja korisnicki id ili ime mapira na datoteku (npr. `GET /files/:name`) — rijesi i potvrdi da je unutar osnovnog direktorija prije citanja. Ako spremas prikazno ime, escapeaj ga pri renderiranju i pazljivo postavi `Content-Disposition: attachment; filename=...` da izbjegnes injekciju zaglavlja.