diff --git a/src/web/routes/html-view-routes.ts b/src/web/routes/html-view-routes.ts
index 0cb77cfa..9814c52e 100644
--- a/src/web/routes/html-view-routes.ts
+++ b/src/web/routes/html-view-routes.ts
@@ -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)
diff --git a/src/web/schemas.ts b/src/web/schemas.ts
index d242a625..b88fff66 100644
--- a/src/web/schemas.ts
+++ b/src/web/schemas.ts
@@ -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.
*
diff --git a/test/routes/html-view-routes.test.ts b/test/routes/html-view-routes.test.ts
index d7ef590e..6fb09b72 100644
--- a/test/routes/html-view-routes.test.ts
+++ b/test/routes/html-view-routes.test.ts
@@ -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);