diff --git a/src/web/routes/html-view-routes.ts b/src/web/routes/html-view-routes.ts index 9814c52e..d440c00d 100644 --- a/src/web/routes/html-view-routes.ts +++ b/src/web/routes/html-view-routes.ts @@ -175,6 +175,12 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort): reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); return; } + // The page URL names the RESOLVED file, so it has to be HTML too: a + // `report.html -> notes.txt` link would otherwise mint a page for a text file. + if (!HTML_VIEW_EXTENSIONS.has(extensionOf(resolvedPath))) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not an HTML file')); + return; + } // The serving route never descends into a dot segment below the capability // root; the HTML file's own path gets the same rule, or `.cache/r.html` would diff --git a/test/routes/html-view-routes.test.ts b/test/routes/html-view-routes.test.ts index 6fb09b72..a8671c75 100644 --- a/test/routes/html-view-routes.test.ts +++ b/test/routes/html-view-routes.test.ts @@ -78,6 +78,9 @@ describe('html-view routes', () => { it('refuses non-HTML files and missing files', async () => { expect((await open(join(workspace, 'data.json'))).statusCode).toBe(400); expect((await open(join(workspace, 'missing.html'))).statusCode).toBe(404); + // An .html name that links to something else is judged by its target. + symlinkSync(join(workspace, 'data.json'), join(workspace, 'data.html')); + expect((await open(join(workspace, 'data.html'))).statusCode).toBe(400); }); it('serves the page sandboxed, never same-origin', async () => {