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);