put the open plan in the url, and stop /api/plans reading outside its folder

/plans/:name, no redirect guard: the bare route is 'no plan open', which is a
real state, so the auto-select-first effect is deleted rather than turned into
a Navigate. The picker stays a native select — chrome for one document, not a
master list — but it navigates instead of setting state.

Reading the server route for this turned up a path traversal: hono
percent-decodes params, so GET /api/plans/..%2F..%2Fsecret reached
join(plansDir, '../../secret.md'). basename() the param.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 12:41:55 +00:00
co-authored by Claude Opus 5
parent 32aa1e7cc3
commit 1dc0eddde0
4 changed files with 55 additions and 40 deletions
+6 -2
View File
@@ -1,6 +1,6 @@
import { createRouter } from '../../create-router';
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { basename, join } from 'node:path';
const plansDir = join(process.cwd(), 'plans');
@@ -17,7 +17,11 @@ plansRouter.get('/', async (ctx) => {
});
plansRouter.get('/:name', async (ctx) => {
const name = ctx.req.param('name');
// A single path segment is not a single *name*: hono percent-decodes params, so `..%2F..%2Fsecret`
// arrives here as `../../secret` and `join` would happily walk out of plansDir. Verified against hono
// directly. Auth limits the blast radius to the owner's own token, and the `.md` suffix limits it to
// markdown, but "read any .md on the disk" is not what this endpoint is for.
const name = basename(ctx.req.param('name'));
const filePath = join(plansDir, `${name}.md`);
const file = Bun.file(filePath);
if (!(await file.exists())) return ctx.text('Not found', 404);