refactor(html-view): validate the mint body with a Zod schema

POST /api/sessions/:id/html-view read `path` off an unchecked cast. It now
goes through HtmlViewOpenSchema in schemas.ts (a non-empty string of at
most 4096 characters without NUL or line breaks, no other keys) via
parseBody, like the other file routes, and keeps its "Missing path
parameter" message for an absent path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-13 17:10:24 +02:00
1 parent 69aa5724e7
commit 432faf7254
3 files changed
+32 -7

No files matched your search

+3 -7
View File
@@ -32,7 +32,8 @@ import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from '
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { HTML_VIEW_PREFIX, htmlViewCapabilities } from '../../html-view-capabilities.js';
import { CASES_DIR, findSessionOrFail, ownerFor, validateSessionFilePath } from '../route-helpers.js';
import { CASES_DIR, findSessionOrFail, ownerFor, parseBody, validateSessionFilePath } from '../route-helpers.js';
import { HtmlViewOpenSchema } from '../schemas.js';
import { getUserSpacesDir } from '../../config/multiuser.js';
import { exceedsDownloadLimit } from '../../config/buffer-limits.js';
import type { SessionPort } from '../ports/index.js';
@@ -133,12 +134,7 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
app.post('/api/sessions/:id/html-view', async (req, reply) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const body = (req.body || {}) as { path?: string };
if (!body.path || typeof body.path !== 'string') {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
return;
}
const body = parseBody(HtmlViewOpenSchema, req.body ?? {});
if (session.remote) {
reply
.code(400)
+18
View File
@@ -127,6 +127,24 @@ export const FileWriteSchema = z
})
.strict();
/**
* Body validation for `POST /api/sessions/:id/html-view` (render a local HTML file
* as a sandboxed page). `path` is absolute or relative to the session workspace;
* the extension, containment, guard and broad-root checks are the route's
* (html-view-routes.ts), after parsing.
*/
export const HtmlViewOpenSchema = z
.object({
path: z
.string({ error: 'Missing path parameter' })
.min(1, 'Missing path parameter')
.max(4096)
.refine((p) => !p.includes('\0') && !p.includes('\n') && !p.includes('\r'), {
message: 'Invalid path',
}),
})
.strict();
/**
* The run-mode ids the API currently accepts: every ENABLED registry entry.
*
+11
View File
@@ -64,6 +64,17 @@ describe('html-view routes', () => {
expect(res.statusCode).toBe(200);
});
it('validates the body', async () => {
const post = (payload: unknown) =>
harness.app.inject({ method: 'POST', url: `/api/sessions/${sid}/html-view`, payload: payload as object });
for (const payload of [{}, { path: '' }, { path: 42 }, { path: 'a\nb.html' }, { path: 'index.html', extra: 1 }]) {
const res = await post(payload);
expect(res.statusCode, JSON.stringify(payload)).toBe(400);
expect(JSON.parse(res.body).success).toBe(false);
}
expect(JSON.parse((await post({})).body).error).toBe('Missing path parameter');
});
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);