Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion shell/about.html
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@
<div class="title"><span class="t">T</span><span class="rest">andem</span></div>
<div class="subtitle">First-Party OpenClaw Companion Browser</div>
<div class="status-badge">Developer Preview</div>
<div class="version" id="version">v0.62.9</div>
<div class="version" id="version">v0.62.12</div>
<div class="info">
Built specifically for human-AI collaboration with OpenClaw.<br>
Maintained in the same ecosystem as OpenClaw, with security and local control built in.
Expand Down
17 changes: 11 additions & 6 deletions src/api/routes/previews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 {
Expand Down Expand Up @@ -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` });
Expand Down Expand Up @@ -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` });
Expand All @@ -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(`<!DOCTYPE html><html><body>
<h1>Preview not found</h1>
<p>No preview with id <code>${id}</code> exists.</p>
<p>No preview with id <code>${escapeHtml(id)}</code> exists.</p>
<p><a href="http://127.0.0.1:8765/previews">View all previews</a></p>
</body></html>`);
return;
Expand All @@ -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` });
Expand Down
Loading