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 () => {