From c14a548cb9c2da0e1053ea9759b8866fe25f7d74 Mon Sep 17 00:00:00 2001 From: tdurieux Date: Sun, 6 Sep 2026 09:17:42 +0200 Subject: [PATCH] fix: enforce content policies using original file types --- src/core/AnonymizedFile.ts | 4 ++-- src/core/zipStream.ts | 4 ++-- src/server/routes/file.ts | 3 ++- src/server/routes/webview.ts | 1 + test/production-regressions.test.js | 28 ++++++++++++++++++++++++++++ 5 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/core/AnonymizedFile.ts b/src/core/AnonymizedFile.ts index 877bf48..73f4782 100644 --- a/src/core/AnonymizedFile.ts +++ b/src/core/AnonymizedFile.ts @@ -343,7 +343,7 @@ export default class AnonymizedFile { async anonymizedContent() { const anonymizer = this.repository.generateAnonymizeTransformer( - this.anonymizedPath + await this.originalPath() ); if (!config.STREAMER_ENTRYPOINT) { // collect the content locally @@ -385,7 +385,7 @@ export default class AnonymizedFile { async send(res: Response): Promise { const anonymizer = this.repository.generateAnonymizeTransformer( - this.anonymizedPath + await this.originalPath() ); // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve, reject) => { diff --git a/src/core/zipStream.ts b/src/core/zipStream.ts index e651d91..2d527c2 100644 --- a/src/core/zipStream.ts +++ b/src/core/zipStream.ts @@ -149,7 +149,7 @@ export async function streamAnonymizedZip( entry.path.substring(entry.path.indexOf("/") + 1), compiledTerms ); - if (!isEntryAllowed(fileName, opt.contentOptions)) { + if (!isEntryAllowed(entry.path, opt.contentOptions)) { entry.autodrain(); return; } @@ -159,7 +159,7 @@ export async function streamAnonymizedZip( // isText=false for every file, so the zip ships unanonymized. const anonymizer = new AnonymizeTransformer({ ...opt.anonymizerOptions, - filePath: fileName, + filePath: entry.path, }); const st = entry.pipe(anonymizer); archive.append(st, { name: fileName }); diff --git a/src/server/routes/file.ts b/src/server/routes/file.ts index 8bf4f56..f649820 100644 --- a/src/server/routes/file.ts +++ b/src/server/routes/file.ts @@ -105,6 +105,7 @@ router.get( repository: repo, anonymizedPath, }); + const originalPath = await f.originalPath(); if (!f.isFileSupported()) { throw new AnonymousError("file_not_supported", { httpStatus: 403, @@ -115,7 +116,7 @@ router.get( res.attachment( anonymizedPath.substring(anonymizedPath.lastIndexOf("/") + 1) ); - } else if (isScriptableDocument(anonymizedPath)) { + } else if (isScriptableDocument(originalPath) || isScriptableDocument(anonymizedPath)) { // A repository's own .html/.svg is untrusted content served from our // origin: opening it renders it as a document, and any script in it // would run as the site itself (session cookie, same-origin fetches diff --git a/src/server/routes/webview.ts b/src/server/routes/webview.ts index 7d45393..1f83754 100644 --- a/src/server/routes/webview.ts +++ b/src/server/routes/webview.ts @@ -151,6 +151,7 @@ async function webView(req: express.Request, res: express.Response) { } } + await f.originalPath(); if (!f.isFileSupported()) { throw new AnonymousError("file_not_supported", { httpStatus: 400, diff --git a/test/production-regressions.test.js b/test/production-regressions.test.js index 0ae65a7..5854c7f 100644 --- a/test/production-regressions.test.js +++ b/test/production-regressions.test.js @@ -119,6 +119,34 @@ describe("production regressions", function () { throw new Error("expected rejection"); } catch (error) { expect(error.message).to.equal("token_expired"); } }); + + function response() { + return { headers: {}, header(key, value) { this.headers[key] = value; return this; }, + contentType() { return this; }, status(value) { this.statusCode = value; return this; }, end() {}, send() {} }; + } + function fileRoute(originalName) { + const File = require("../src/core/AnonymizedFile").default; + const utils = require("../src/server/routes/route-utils"); + const repo = { options: { pdf: false, image: false, terms: [] }, model: { source: { commit: "commit-1" }, options: {} }, isReady: async () => true, countView: async () => {} }; + stub(utils, "getRepo", async () => repo); + stub(File.prototype, "originalPath", async function () { this._file = { name: originalName, path: "" }; return originalName; }); + stub(File.prototype, "sha", async () => "sha"); + stub(File.prototype, "send", async () => {}); + const handler = require("../src/server/routes/file").default.stack[0].route.stack[0].handle; + return { repo, handler, req: { url: "/repo/file/page.txt?v=old", protocol: "https", hostname: "host", params: { repoId: "repo" }, query: { v: "old" }, headers: {} } }; + } + it("gates PDFs using their original extension", async function () { + const { handler, req } = fileRoute("report.pdf"); + let failure; + stub(require("../src/server/routes/route-utils"), "handleError", error => { failure = error; }); + await handler(req, response()); + expect(failure.message).to.equal("file_not_supported"); + }); + it("sandboxes renamed HTML documents", async function () { + const { handler, req } = fileRoute("page.html"); + const first = response(); await handler(req, first); + expect(first.headers["Content-Security-Policy"]).to.include("sandbox"); + }); it("omits an upstream length when later text is rewritten", async function () { const File = require("../src/core/AnonymizedFile").default; stub(config, "STREAMER_ENTRYPOINT", "");