Nimbus Docs: Case-Sensitive Guards and the Zero-Byte Proc File
Nimbus Docs is an Express 4.18 service in a container running as uid 998 (nimbus).
const UPLOAD = '/srv/nimbus/uploads';
app.get('/download/:name', (req, res) => {
if (req.url.includes('..') || req.url.includes('%2e')) return res.sendStatus(400);
res.sendFile(path.join(UPLOAD, req.params.name));
});
/srv/nimbus/uploads is a bind mount of an object bucket. /srv/nimbus/config.json (mode 0640, owned nimbus:nimbus) holds the Postgres DSN. The container has no outbound network but a normal /proc. Access logs show roughly 40 blocked requests a day, all of which contained a literal ... Express percent-decodes route params with decodeURIComponent before populating req.params; sendFile is called with an absolute path and no root option. The service also exposes /healthz and logs with morgan in combined format.
1. Bypass the guard and read `/srv/nimbus/config.json`. Give the exact request line and explain, step by step, what each control does with your input.
Read the worked answer
GET /download/%2E%2E%2Fconfig.json HTTP/1.1
Host: docs.nimbus.internal
Why it survives every layer:
- The guard inspects
req.url(raw, undecoded) for..and for lowercase%2e.%2E%2Eis uppercase hex, so both substrings miss. String matching on an encoded representation is the bug: it has to fail closed on decode, not pattern-match on bytes. - Routing still matches
/download/:name, because the encoded%2Fis not a path separator to Express's router - the whole thing is one segment. decodeURIComponentthen turnsreq.params.nameinto../config.json.path.join(UPLOAD, '../config.json')normalises before anything else sees it:/srv/nimbus/uploads/../config.json→/srv/nimbus/config.json. Notejoinconcatenates (it does not discard the left side the waypath.resolvewould), so an absolutenamewould have become/srv/nimbus/uploads/etc/passwd- traversal is the useful direction here.sendFilereceives a fully normalised absolute path with no..left in it, sosend's own dot-segment and dotfile protections have nothing to detect; without arootoption they cannot apply anyway.fs.openfollows the path, uid 998 owns the file → 200 with the DSN.
Note the guard is also bypassable as ..%2F only when the checker misses it - here the lowercase %2e rule covers that, which is precisely why uppercase hex is the gap. A quick sanity probe: /download/%2E%2E%2F%2E%2E%2Fetc%2Fpasswd returning 400 with the same body as a normal 404 tells you the guard fired, not that the file is missing.
2. You want the DSN even if `config.json` is rotated away. Evaluate `/proc/self/environ` and `/proc/self/cwd` as targets through this same primitive.
Read the worked answer
/proc/self/environ - readable but useless through sendFile. Permissions are fine (0400, owned by uid 998, the app's own uid), and the payload GET /download/%2E%2E%2F%2E%2E%2Fproc%2Fself%2Fenviron resolves correctly. The failure is in send: it stats the target and sets Content-Length from st_size. Procfs files report size 0, so you get 200 OK, Content-Length: 0, and an empty body. The data only exists when read sequentially, which is not what a size-driven static sender does. Do not conclude "no secrets in env" from an empty 200 - conclude "wrong exfiltration primitive".
/proc/self/cwd is a symlink into the container filesystem. Through this endpoint it is only useful as reconnaissance: request /proc/self/cwd/package.json to confirm the app root is /app and to read package.json, .env, and any committed config relative to it. fs.open follows symlinks, so /proc/self/root/srv/nimbus/config.json also works - same file, different path, which is handy if a WAF or log rule ever keys on the literal string srv/nimbus.
Practical order of fire: config.json → /proc/self/cwd/.env → /proc/self/cwd/package.json → /proc/1/environ (the parent process, often the supervisor, and again size 0 - so instead look for a mounted .env or an app config file). The reliable lesson: procfs plus a stat-based static file server yields empty bodies; always pivot to a real on-disk file that the process can read.
3. Server-side fix. Contrast `req.params.name.replace(/[.]{2}/g,'')`, `path.basename(name)`, and a resolved-prefix check, and state the check you would actually ship.
Read the worked answer
replace(/[.]{2}/g,'') - broken by collapse. The replacement runs once, left to right, on the raw string. ....//config.json → ../config.json; ..././ variants behave similarly, and if you decode first you must also reason about %252e%252e%252f arriving as literal %2e%2e%2f after a single decode. Any blacklist that mutates rather than validates is a bug waiting for the next encoding trick.
path.basename(name) - safe but over-restrictive. It strips every directory component, so traversal is impossible, but the endpoint's legitimate use case (subdirectories per tenant) dies with it. It is the right choice only if flat filenames are truly the contract.
Resolved-prefix check - correct, and the one to ship:
const p = path.resolve(UPLOAD, req.params.name);
if (p !== UPLOAD && !p.startsWith(UPLOAD + path.sep)) return res.sendStatus(400);
res.sendFile(path.basename(req.params.name), {root: UPLOAD, dotfiles: 'deny'});
Details that matter:
path.resolvenormalises and collapses.., so the comparison sees the real target.startsWith(UPLOAD)without the trailing separator lets/srv/nimbus/uploads-evilpass - always appendpath.sep, and handle the exact-equal case separately.- Use
sendFile's ownrootoption so the library enforces containment as well; defence in depth costs nothing here. - Decode exactly once and validate the decoded value; never validate the encoded form.
- Add a symlink check (
fs.realpathand re-test the prefix) if tenants can create symlinks inside the bucket mount. - The guard belongs in a middleware applied to every file-serving route, not copy-pasted per handler - the same pattern almost certainly appears in the upload and thumbnail endpoints.