Claude Fable 5.1 & GPT-6 Astra packages are live

Path Traversal

Free

Confining file access to an intended directory — resolve-then-verify, symlink and archive pitfalls, and why blocking "../" does not work.

197 lines6.8 KB Glm Security
targetModels
GLM-5.3GLM-5.2GLM-5 FamilyGLM-4.6Future GLM Models
name
path-traversal
category
Security
description
Confining file access to an intended directory — resolve-then-verify, symlink and archive pitfalls, and why blocking "../" does not work.
license
MIT
author
Agent.md maintainers
last-verified
reviewed-by
unreviewed
<!-- Generated from models/_canonical by scripts/build-model-variants.js. Edit the canonical source, not this file. Behavioural profile for GLM: scripts/model-profiles.json -->

#Task boundary

  1. Implement only what the task names; no extra abstractions or files.
  2. English-only comments and identifiers.
  3. Stop when the checklist passes.

#Purpose

Rules for reading, writing and serving files when any part of the path derives from input.

The rule underneath everything: resolve the final absolute path, then verify it is inside the directory you intended. Inspecting the input string is not a control — it is a guess about how the operating system will interpret it.


#Resolve, then verify

js
import path from "node:path";
import fs from "node:fs/promises";

const ROOT = path.resolve("/srv/uploads");

function safeJoin(root, userPath) {
  const target = path.resolve(root, userPath);
  // The separator matters: "/srv/uploads-evil" starts with "/srv/uploads".
  if (target !== root && !target.startsWith(root + path.sep)) {
    throw new Error("path escapes root");
  }
  return target;
}

const file = safeJoin(ROOT, req.params.name);
await fs.readFile(file);

Two details do the work:

  1. path.resolve normalises first. It collapses .., ., duplicate separators and mixed forms before the check, so the comparison is against what the filesystem will actually open.
  2. The trailing separator in the comparison. Without + path.sep, the directory /srv/uploads-evil passes a plain startsWith("/srv/uploads").

Never validate by string inspection:

js
if (name.includes("..")) reject();      // insufficient

That check is defeated by URL encoding (%2e%2e%2f), double encoding (%252e%252e%252f), overlong UTF-8, backslashes on Windows, and null bytes. Decoding happens before your check in some stacks and after it in others — which is precisely why you verify the resolved path instead.

Never concatenate paths with + or a template literal. Use path.resolve or path.join, then verify.


#Absolute paths and drive letters

path.join(root, "/etc/passwd") yields root/etc/passwd, but path.resolve(root, "/etc/passwd") yields /etc/passwd — the absolute argument wins. This is a common and surprising escape.

Reject absolute inputs before resolving:

js
if (path.isAbsolute(userPath)) throw new Error("absolute path rejected");

On Windows also reject drive-relative forms (C:file), UNC paths (\\server\share) and reserved device names (CON, PRN, AUX, NUL, COM1COM9, LPT1LPT9).


#Symlinks

A path can pass every string check and still resolve outside the root, because a component is a symbolic link.

js
// Verify what the path actually points at, not what it looks like
const real = await fs.realpath(target);
if (real !== ROOT && !real.startsWith(ROOT + path.sep)) {
  throw new Error("symlink escapes root");
}

realpath resolves every link in the chain. Note the residual TOCTOU window: the link can change between the check and the open. Where it matters, open the file first and validate the descriptor — O_NOFOLLOW, or fs.open then fstat and compare st_dev/st_ino.


#Uploads

  1. Never persist the client's filename. Generate your own — a UUID or a content hash — and store the original name as metadata only.
  2. Derive the extension from sniffed content type, not from the supplied name.
  3. Store outside the web root, or in object storage, so an uploaded file cannot be requested as a script.
  4. Serve with Content-Disposition: attachment and X-Content-Type-Options: nosniff.
  5. Serve user content from a separate origin so a stored HTML file cannot reach your cookies — see Security/xss.

#Archive extraction — Zip Slip

An archive entry may contain ../, an absolute path, or be a symlink. Extracting without checking writes outside the destination.

js
for (const entry of entries) {
  const target = safeJoin(DEST, entry.name);   // same check as above
  if (entry.isSymlink) continue;               // or verify the link target too
  await writeFile(target, entry.data);
}

Also bound the extraction itself: cap total uncompressed bytes, entry count and nesting depth. A 42 KB archive expanding to petabytes is a zip bomb, and the denial of service arrives long before any traversal does.


#Static file serving

Prefer a hardened server or a maintained library over hand-rolled path handling — express.static, send, nginx root. They already handle encoding, symlinks, range requests and dotfiles.

If you must handle it yourself:

  1. Deny dotfiles by default (.git, .env, .ssh).
  2. Do not follow symlinks unless deliberate.
  3. Reject null bytes (%00) outright; historically they truncated paths in C string handling.
  4. Canonicalise once, at the boundary, and pass the resolved path onward.

#Anti-patterns

Anti-patternWhy it failsFix
if (p.includes(".."))Encoding, double encoding, backslashesResolve, then verify
root + "/" + userPathNo normalisation; .. survivespath.resolve then verify
startsWith(root) without a separator/srv/uploads-evil passesCompare root + path.sep
Ignoring absolute inputspath.resolve lets them win outrightReject path.isAbsolute
Checking the string but not the linkSymlink escapes the rootfs.realpath
Saving the client's filenameTraversal and overwrite via the nameServer-generated name
Extracting archives uncheckedZip Slip writes anywhereVerify each entry path
No extraction limitsZip bomb exhausts diskCap size, count, depth
Uploads served from the app originStored XSS with cookie accessSeparate origin, nosniff

#Checklist

  • Every input-derived path is resolved to absolute before use
  • The resolved path is verified against root + path.sep
  • Absolute paths, drive-relative forms and UNC paths are rejected
  • Windows reserved device names are rejected
  • realpath is used where symlinks are possible
  • Uploads are stored under server-generated names, outside the web root
  • Upload type comes from sniffed content, not the supplied extension
  • Archive entries are path-checked individually; symlink entries handled
  • Extraction caps total size, entry count and depth
  • Dotfiles and null bytes are rejected by the static file path
  • User content is served from a separate origin with nosniff