From 41bef276a5789fc6a1b8de3d4f17914be732561c Mon Sep 17 00:00:00 2001 From: Trygve Lie Date: Fri, 21 Aug 2026 12:01:17 +0200 Subject: [PATCH 1/3] fix: reject path traversal in alias extras and validate redirect location Extras containing parent directory references (..), backslashes, or encoded path separators are rejected before the alias redirect is constructed. A secondary check ensures the constructed location does not resolve to a protocol-relative or absolute URL outside the expected origin. --- lib/handlers/alias.get.js | 44 ++++++++++++++++++++++++++++ test/handlers/alias.get.js | 59 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/lib/handlers/alias.get.js b/lib/handlers/alias.get.js index d0296e16..056d7f41 100644 --- a/lib/handlers/alias.get.js +++ b/lib/handlers/alias.get.js @@ -4,6 +4,8 @@ import HttpError from "http-errors"; import abslog from "abslog"; import Metrics from "@metrics/client"; +const RE_EXTERNAL_LOCATION = /^\/\/|^https?:\/\//i; + import { decodeUriComponent, readJSON } from "../utils/utils.js"; import { createURIToTargetOfAlias } from "../utils/path-builders-uri.js"; import { createFilePathToAlias } from "../utils/path-builders-fs.js"; @@ -73,6 +75,36 @@ const AliasGet = class AliasGet { throw e; } + // Reject extras that contain path traversal sequences or encoded + // characters that could escape the package path after decoding. + if (pExtra) { + let fullyDecoded; + try { + fullyDecoded = decodeURIComponent(pExtra); + } catch { + // invalid encoding — reject + const e = new HttpError.NotFound(); + end({ labels: { success: false, status: e.status } }); + throw e; + } + if ( + fullyDecoded.includes("..") || + fullyDecoded.includes("\\") || + fullyDecoded.includes("//") || + fullyDecoded.includes("%2F") || + fullyDecoded.includes("%2f") || + fullyDecoded.includes("%5C") || + fullyDecoded.includes("%5c") + ) { + this._log.debug( + `alias:get - Path traversal detected in extras - Pathname: ${pExtra}`, + ); + const e = new HttpError.NotFound(); + end({ labels: { success: false, status: e.status } }); + throw e; + } + } + const url = originalUrl(req); const org = this._orgRegistry.get(url.hostname); @@ -96,6 +128,18 @@ const AliasGet = class AliasGet { const obj = await readJSON(this._sink, path); const location = createURIToTargetOfAlias({ extra: pExtra, ...obj }); + // Defence-in-depth: reject any constructed location that starts with + // '//' (protocol-relative) or an absolute URL scheme, which would + // redirect outside the expected origin. + if (RE_EXTERNAL_LOCATION.test(location)) { + this._log.debug( + `alias:get - Constructed location escapes expected origin - Pathname: ${path}`, + ); + const e = new HttpError.NotFound(); + end({ labels: { success: false, status: e.status } }); + throw e; + } + const outgoing = new HttpOutgoing(); outgoing.cacheControl = this._cacheControl; outgoing.statusCode = 302; diff --git a/test/handlers/alias.get.js b/test/handlers/alias.get.js index 18786d61..9d516e58 100644 --- a/test/handlers/alias.get.js +++ b/test/handlers/alias.get.js @@ -45,3 +45,62 @@ test("alias.get() - URL parameters is URL encoded", async () => { ".location should be decoded", ); }); + +// Regression: path traversal in extras escaped the package path and produced +// a protocol-relative redirect target that resolves outside the expected origin. +test("alias.get() - path traversal in extras via double-encoded slashes - should throw", async () => { + const sink = new Sink(); + const alias = new Alias({ + alias: "2", + name: "@warp-ds/css", + type: "pkg", + org: "localhost", + }); + alias.version = "1.5.3"; + sink.set("/local/pkg/@warp-ds/css/2.alias.json", JSON.stringify(alias)); + + const h = new Handler({ sink }); + const req = new Request(); + + // Simulates the decoded extras from: + // /pkg/@warp-ds/css/v2/..%252F..%252F..%252F..%252F%255Cattacker.example + // after one round of percent-decoding by the HTTP framework. + await assert.rejects( + () => + h.handler( + req, + "pkg", + "@warp-ds/css", + "2", + "..%2F..%2F..%2F..%2F%5Cattacker.example", + ), + { statusCode: 404 }, + ); +}); + +test("alias.get() - path traversal in extras via literal dots - should throw", async () => { + const sink = new Sink(); + const alias = new Alias({ + alias: "2", + name: "@warp-ds/css", + type: "pkg", + org: "localhost", + }); + alias.version = "1.5.3"; + sink.set("/local/pkg/@warp-ds/css/2.alias.json", JSON.stringify(alias)); + + const h = new Handler({ sink }); + const req = new Request(); + + await assert.rejects( + () => + h.handler( + req, + "pkg", + "@warp-ds/css", + "2", + "../../../../attacker.example", + ), + { statusCode: 404 }, + ); +}); From ae6500a97875c73ecbba188557c8c704a5c4cb66 Mon Sep 17 00:00:00 2001 From: Trygve Lie Date: Fri, 21 Aug 2026 12:50:58 +0200 Subject: [PATCH 2/3] refactor: remove redundant extras traversal check from alias handler validators.extra() from @eik/common now performs this validation. The handler retains only the defence-in-depth location check that ensures the constructed redirect stays within the expected origin. --- lib/handlers/alias.get.js | 30 ------------------------------ package-lock.json | 2 +- package.json | 2 +- 3 files changed, 2 insertions(+), 32 deletions(-) diff --git a/lib/handlers/alias.get.js b/lib/handlers/alias.get.js index 056d7f41..3429eb52 100644 --- a/lib/handlers/alias.get.js +++ b/lib/handlers/alias.get.js @@ -75,36 +75,6 @@ const AliasGet = class AliasGet { throw e; } - // Reject extras that contain path traversal sequences or encoded - // characters that could escape the package path after decoding. - if (pExtra) { - let fullyDecoded; - try { - fullyDecoded = decodeURIComponent(pExtra); - } catch { - // invalid encoding — reject - const e = new HttpError.NotFound(); - end({ labels: { success: false, status: e.status } }); - throw e; - } - if ( - fullyDecoded.includes("..") || - fullyDecoded.includes("\\") || - fullyDecoded.includes("//") || - fullyDecoded.includes("%2F") || - fullyDecoded.includes("%2f") || - fullyDecoded.includes("%5C") || - fullyDecoded.includes("%5c") - ) { - this._log.debug( - `alias:get - Path traversal detected in extras - Pathname: ${pExtra}`, - ); - const e = new HttpError.NotFound(); - end({ labels: { success: false, status: e.status } }); - throw e; - } - } - const url = originalUrl(req); const org = this._orgRegistry.get(url.hostname); diff --git a/package-lock.json b/package-lock.json index 969fc655..f1b58ef1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "2.2.1", "license": "MIT", "dependencies": { - "@eik/common": "5.2.0", "@eik/sink": "1.2.7", "@eik/sink-file-system": "2.2.0", "@eik/sink-memory": "2.2.1", @@ -24,6 +23,7 @@ "tar": "7.5.22" }, "devDependencies": { + "@eik/common": "5.2.0", "@eik/eslint-config": "2.0.15", "@eik/prettier-config": "1.0.2", "@eik/semantic-release-config": "1.0.19", diff --git a/package.json b/package.json index db31b837..399011f0 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "author": "", "license": "MIT", "dependencies": { - "@eik/common": "5.2.0", "@eik/sink": "1.2.7", "@eik/sink-file-system": "2.2.0", "@eik/sink-memory": "2.2.1", @@ -44,6 +43,7 @@ "tar": "7.5.22" }, "devDependencies": { + "@eik/common": "5.2.0", "@eik/eslint-config": "2.0.15", "@eik/prettier-config": "1.0.2", "@eik/semantic-release-config": "1.0.19", From ec25b5c41149ad8ffd8354779cb64dfd7ecc5e8e Mon Sep 17 00:00:00 2001 From: Trygve Lie Date: Fri, 21 Aug 2026 13:02:00 +0200 Subject: [PATCH 3/3] chore: bump @eik/common to 5.2.1 --- package-lock.json | 23 +++++++++++++++++++---- package.json | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index f1b58ef1..0d633b8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "tar": "7.5.22" }, "devDependencies": { - "@eik/common": "5.2.0", + "@eik/common": "5.2.1", "@eik/eslint-config": "2.0.15", "@eik/prettier-config": "1.0.2", "@eik/semantic-release-config": "1.0.19", @@ -147,9 +147,10 @@ } }, "node_modules/@eik/common": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@eik/common/-/common-5.2.0.tgz", - "integrity": "sha512-3/jkLfOBW06xqxpC/fRgenuGwlFEtCDxWrbz5ML2lWFbefeuUpuuMgqI7yp2UM6vLIP9jEMrvXEQeSMt/gUUTQ==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@eik/common/-/common-5.2.1.tgz", + "integrity": "sha512-hEWz+bptiqiF/pgqZ7Y4LUETSlC0anhfm1drlhnSsiHJPsz/Rmjloc9mFtjZqk0yohnjFDJNFjEyvi7jJL7kJQ==", + "dev": true, "license": "ISC", "dependencies": { "ajv": "8.20.0", @@ -257,6 +258,20 @@ "@metrics/client": "2.5.5" } }, + "node_modules/@eik/sink-memory/node_modules/@eik/common": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@eik/common/-/common-5.2.0.tgz", + "integrity": "sha512-3/jkLfOBW06xqxpC/fRgenuGwlFEtCDxWrbz5ML2lWFbefeuUpuuMgqI7yp2UM6vLIP9jEMrvXEQeSMt/gUUTQ==", + "license": "ISC", + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "mime-types": "3.0.2", + "semver": "7.8.5", + "tinyglobby": "0.2.17", + "validate-npm-package-name": "8.0.0" + } + }, "node_modules/@eik/typescript-config": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@eik/typescript-config/-/typescript-config-1.0.2.tgz", diff --git a/package.json b/package.json index 399011f0..a78119ff 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "tar": "7.5.22" }, "devDependencies": { - "@eik/common": "5.2.0", + "@eik/common": "5.2.1", "@eik/eslint-config": "2.0.15", "@eik/prettier-config": "1.0.2", "@eik/semantic-release-config": "1.0.19",