Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions lib/handlers/alias.get.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 19 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
59 changes: 59 additions & 0 deletions test/handlers/alias.get.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);
});