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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user