diff --git a/CHANGELOG.md b/CHANGELOG.md index cd18a76..5af3875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to Tandem Browser will be documented in this file. +## [v0.62.12] - 2026-03-17 + +- fix: sanitize preview IDs to prevent path traversal and reflected XSS (security) + ## [v0.62.9] - 2026-03-16 - fix: remove all duplicate files with spaces in names from repo diff --git a/package-lock.json b/package-lock.json index a8c772a..0c2ccbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tandem-browser", - "version": "0.62.9", + "version": "0.62.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tandem-browser", - "version": "0.62.9", + "version": "0.62.12", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index d48fc44..7041161 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tandem-browser", - "version": "0.62.9", + "version": "0.62.12", "description": "First-party OpenClaw companion browser for human-AI collaboration with built-in security controls", "main": "dist/main.js", "author": "Tandem Browser contributors", diff --git a/shell/about.html b/shell/about.html index bd6951e..f9e3824 100644 --- a/shell/about.html +++ b/shell/about.html @@ -114,7 +114,7 @@
Tandem
First-Party OpenClaw Companion Browser
Developer Preview
-
v0.62.9
+
v0.62.12
Built specifically for human-AI collaboration with OpenClaw.
Maintained in the same ecosystem as OpenClaw, with security and local control built in. diff --git a/src/api/routes/previews.ts b/src/api/routes/previews.ts index e834694..c65dd18 100644 --- a/src/api/routes/previews.ts +++ b/src/api/routes/previews.ts @@ -18,6 +18,7 @@ import fs from 'fs'; import path from 'path'; import { tandemDir } from '../../utils/paths'; import { handleRouteError } from '../../utils/errors'; +import { assertSinglePathSegment, escapeHtml, resolvePathWithinRoot } from '../../utils/security'; import type { RouteContext } from '../context'; import { createLogger } from '../../utils/logger'; @@ -32,7 +33,7 @@ function previewsDir(): string { } function previewPath(id: string): string { - return path.join(previewsDir(), `${id}.json`); + return resolvePathWithinRoot(previewsDir(), `${id}.json`); } function slugify(title: string): string { @@ -188,7 +189,8 @@ export function registerPreviewRoutes(router: Router, ctx: RouteContext): void { // Update an existing preview (triggers live reload in the browser) router.put('/preview/:id', (req: Request, res: Response) => { try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const rawId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const id = assertSinglePathSegment(rawId, 'preview ID'); const existing = readPreview(id); if (!existing) { res.status(404).json({ error: `Preview '${id}' not found` }); @@ -222,7 +224,8 @@ export function registerPreviewRoutes(router: Router, ctx: RouteContext): void { // Serve preview metadata (used by live reload polling) router.get('/preview/:id/meta', (req: Request, res: Response) => { try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const rawId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const id = assertSinglePathSegment(rawId, 'preview ID'); const preview = readPreview(id); if (!preview) { res.status(404).json({ error: `Preview '${id}' not found` }); @@ -238,12 +241,13 @@ export function registerPreviewRoutes(router: Router, ctx: RouteContext): void { // Serve the preview HTML page router.get('/preview/:id', (req: Request, res: Response) => { try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const rawId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const id = assertSinglePathSegment(rawId, 'preview ID'); const preview = readPreview(id); if (!preview) { res.status(404).send(`

Preview not found

-

No preview with id ${id} exists.

+

No preview with id ${escapeHtml(id)} exists.

View all previews

`); return; @@ -259,7 +263,8 @@ export function registerPreviewRoutes(router: Router, ctx: RouteContext): void { // Delete a preview router.delete('/preview/:id', (req: Request, res: Response) => { try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const rawId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const id = assertSinglePathSegment(rawId, 'preview ID'); const p = previewPath(id); if (!fs.existsSync(p)) { res.status(404).json({ error: `Preview '${id}' not found` });