diff --git a/lib/handlers/alias.get.js b/lib/handlers/alias.get.js index d0296e16..3429eb52 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"; @@ -96,6 +98,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/package-lock.json b/package-lock.json index 969fc655..0d633b8c 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.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 db31b837..a78119ff 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.1", "@eik/eslint-config": "2.0.15", "@eik/prettier-config": "1.0.2", "@eik/semantic-release-config": "1.0.19", 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 }, + ); +});