Skip to content

Commit

Permalink
Remove multiple slashes from asset pathing
Browse files Browse the repository at this point in the history
  • Loading branch information
WillTaylorDev committed Nov 22, 2024
1 parent 6f09f23 commit 3f2348a
Show file tree
Hide file tree
Showing 5 changed files with 174 additions and 40 deletions.
5 changes: 5 additions & 0 deletions .changeset/cyan-laws-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/workers-shared": minor
---

Prevent same-schema attacks
89 changes: 89 additions & 0 deletions fixtures/asset-config/redirects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { SELF } from "cloudflare:test";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { applyConfigurationDefaults } from "../../packages/workers-shared/asset-worker/src/configuration";
import Worker from "../../packages/workers-shared/asset-worker/src/index";
import { getAssetWithMetadataFromKV } from "../../packages/workers-shared/asset-worker/src/utils/kv";
import type { AssetMetadata } from "../../packages/workers-shared/asset-worker/src/utils/kv";

const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;

vi.mock("../../packages/workers-shared/asset-worker/src/utils/kv.ts");
vi.mock("../../packages/workers-shared/asset-worker/src/configuration");
const existsMock = (fileList: Set<string>) => {
vi.spyOn(Worker.prototype, "unstable_exists").mockImplementation(
async (pathname: string) => {
if (fileList.has(pathname)) {
return pathname;
}
}
);
};
const BASE_URL = "http://example.com";

describe("[Asset Worker] `test redirects`", () => {
afterEach(() => {
vi.mocked(getAssetWithMetadataFromKV).mockRestore();
});
beforeEach(() => {
vi.mocked(getAssetWithMetadataFromKV).mockImplementation(
() =>
Promise.resolve({
value: "no-op",
metadata: {
contentType: "no-op",
},
}) as unknown as Promise<
KVNamespaceGetWithMetadataResult<ReadableStream, AssetMetadata>
>
);

vi.mocked(applyConfigurationDefaults).mockImplementation(() => {
return {
html_handling: "none",
not_found_handling: "none",
};
});
});

it("returns 404 for non matched encoded url", async () => {
const files = ["/christmas/starts/november/first.html"];
const requestPath = "/%2f%2fbad.example.com%2f";

existsMock(new Set(files));
const request = new IncomingRequest(BASE_URL + requestPath);
let response = await SELF.fetch(request, { redirect: "manual" });
expect(response.status).toBe(404);
});

it("returns 200 for matched non encoded url", async () => {
const files = ["/you/lost/the/game.bin"];
const requestPath = "/you/lost/the/game.bin";

existsMock(new Set(files));
const request = new IncomingRequest(BASE_URL + requestPath);
let response = await SELF.fetch(request, { redirect: "manual" });
expect(response.status).toBe(200);
});

it("returns redirect for matched encoded url", async () => {
const files = ["/awesome/file.bin"];
const requestPath = "/awesome/file%2ebin";
const finalPath = "/awesome/file.bin";

existsMock(new Set(files));
const request = new IncomingRequest(BASE_URL + requestPath);
let response = await SELF.fetch(request, { redirect: "manual" });
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(finalPath);
});

it("returns 200 for matched non encoded url", async () => {
const files = ["/mylittlepony.png"];
const requestPath = "/mylittlepony.png";

existsMock(new Set(files));
const request = new IncomingRequest(BASE_URL + requestPath);
let response = await SELF.fetch(request, { redirect: "manual" });
expect(response.status).toBe(200);
});
});
77 changes: 77 additions & 0 deletions fixtures/asset-config/url-normalization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { SELF } from "cloudflare:test";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { applyConfigurationDefaults } from "../../packages/workers-shared/asset-worker/src/configuration";
import Worker from "../../packages/workers-shared/asset-worker/src/index";
import { getAssetWithMetadataFromKV } from "../../packages/workers-shared/asset-worker/src/utils/kv";
import type { AssetMetadata } from "../../packages/workers-shared/asset-worker/src/utils/kv";

const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;

vi.mock("../../packages/workers-shared/asset-worker/src/utils/kv.ts");
vi.mock("../../packages/workers-shared/asset-worker/src/configuration");
const existsMock = (fileList: Set<string>) => {
vi.spyOn(Worker.prototype, "unstable_exists").mockImplementation(
async (pathname: string) => {
if (fileList.has(pathname)) {
return pathname;
}
}
);
};
const BASE_URL = "http://example.com";

describe("[Asset Worker] `test redirects`", () => {
afterEach(() => {
vi.mocked(getAssetWithMetadataFromKV).mockRestore();
});
beforeEach(() => {
vi.mocked(getAssetWithMetadataFromKV).mockImplementation(
() =>
Promise.resolve({
value: "no-op",
metadata: {
contentType: "no-op",
},
}) as unknown as Promise<
KVNamespaceGetWithMetadataResult<ReadableStream, AssetMetadata>
>
);

vi.mocked(applyConfigurationDefaults).mockImplementation(() => {
return {
html_handling: "none",
not_found_handling: "none",
};
});
});

it("returns 200 leading encoded double slash", async () => {
const files = ["/blog/index.html"];
const requestPath = "/%2fblog/index.html";

existsMock(new Set(files));
const request = new IncomingRequest(BASE_URL + requestPath);
let response = await SELF.fetch(request);
expect(response.status).toBe(200);
});

it("returns 200 leading non encoded double slash", async () => {
const files = ["/blog/index.html"];
const requestPath = "//blog/index.html";

existsMock(new Set(files));
const request = new IncomingRequest(BASE_URL + requestPath);
let response = await SELF.fetch(request);
expect(response.status).toBe(200);
});

it("returns 404 for non matched url", async () => {
const files = ["/blog/index.html"];
const requestPath = "/%2fexample.com/";

existsMock(new Set(files));
const request = new IncomingRequest(BASE_URL + requestPath);
let response = await SELF.fetch(request);
expect(response.status).toBe(404);
});
});
10 changes: 3 additions & 7 deletions packages/workers-shared/asset-worker/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export const handleRequest = async (
const { pathname, search } = new URL(request.url);

let decodedPathname = decodePath(pathname);
// normalize the path
decodedPathname = decodedPathname.replaceAll('//', '/');
// normalize the path; remove multiple slashes which could lead to same-schema redirects
decodedPathname = decodedPathname.replace(/\/+/g, "/");

const intent = await getIntent(decodedPathname, configuration, exists);

Expand All @@ -44,9 +44,7 @@ export const handleRequest = async (
* Thus we need to redirect if that is different from the decoded version.
* We combine this with other redirects (e.g. for html_handling) to avoid multiple redirects.
*/
if (encodedDestination !== pathname || intent.redirect) {
console.log(encodedDestination, pathname);

if ((encodedDestination !== pathname && intent.asset) || intent.redirect) {
return new TemporaryRedirectResponse(encodedDestination + search);
}

Expand Down Expand Up @@ -643,8 +641,6 @@ const safeRedirect = async (
return null;
}

console.log(file, destination, configuration);

if (!(await exists(destination))) {
const intent = await getIntent(destination, configuration, exists, true);
// return only if the eTag matches - i.e. not the 404 case
Expand Down
33 changes: 0 additions & 33 deletions packages/workers-shared/asset-worker/tests/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,37 +96,4 @@ describe("[Asset Worker] `handleRequest`", () => {

expect(response.status).toBe(200);
});

it("normalizes double slashes", async () => {
const configuration: Required<AssetConfig> = {
html_handling: "none",
not_found_handling: "none",
};
const eTag = "some-etag";
const exists = vi.fn().mockReturnValue(eTag);
const getByETag = vi.fn().mockReturnValue({
readableStream: new ReadableStream(),
contentType: "text/html",
});

let response = await handleRequest(
new Request("https://example.com/abc/def//123/456"),
configuration,
exists,
getByETag
);

expect(response.status).toBe(307);
expect(response.headers.get('location')).toBe('/abc/def/123/456');

response = await handleRequest(
new Request("https://example.com/%2fexample.com/"),
configuration,
exists,
getByETag
);

expect(response.status).toBe(307);
expect(response.headers.get('location')).toBe('/example.com/');
});
});

0 comments on commit 3f2348a

Please sign in to comment.