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