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
+1
View File
@@ -54,6 +54,7 @@ export function App() {
<Route path="/chat/g/*" element={<Dashboard.SessionListPage />} />
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
<Route path="/plans" element={<Dashboard.Plans />} />
<Route path="/plans/:name" element={<Dashboard.Plans />} />
<Route path="/files" element={<Dashboard.FilesScreen />} />
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
@@ -1,61 +1,71 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { useClient } from 'hooks/useClient';
import { Card } from '@/components/Card';
/**
* A plan is a markdown document on disk, so it gets an address: `/plans/:name`. No redirect guard — the
* bare route is "no plan open" and a name that no longer exists gets the empty pane, not a rewritten URL.
*
* The picker stays a native `<select>` rather than becoming a link list. It is chrome for one document,
* not a master list, and a `<select>` is the right control for that on a phone; it navigates instead of
* setting state, which is what M4 was actually about.
*/
export const Plans = () => {
const client = useClient();
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
const navigate = useNavigate();
const selected = useParams<{ name: string }>().name ?? null;
const { data: plans = [] } = useQuery<string[]>({
queryKey: ['plans'],
queryFn: () => client.get<string[]>('/plans'),
});
useEffect(() => {
if (plans.length > 0 && !selectedPlan) {
setSelectedPlan(plans[0]!);
}
}, [plans, selectedPlan]);
const { data: content = '' } = useQuery<string>({
queryKey: ['plans', selectedPlan],
queryFn: () => client.getText(`/plans/${selectedPlan}`),
enabled: !!selectedPlan,
queryKey: ['plans', selected],
queryFn: () => client.getText(`/plans/${encodeURIComponent(selected!)}`),
enabled: !!selected,
});
return (
<div className="flex flex-col h-full p-4">
<Card className="flex-1 overflow-hidden">
{/* Header with plan selector */}
<div className="shrink-0 flex items-center gap-3 px-4 py-2 border-b border-duck-dark/10 bg-background/60">
<span className="text-sm font-medium text-duck-dark/70">Plans</span>
{plans.length > 1 && (
<select
value={selectedPlan ?? ''}
onChange={(ev) => setSelectedPlan(ev.target.value)}
className="text-xs border border-duck-dark/20 rounded px-2 py-1 bg-background/80 text-duck-dark"
>
{plans.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
)}
</div>
<Card className="flex-1 overflow-hidden">
<div className="shrink-0 flex items-center gap-3 px-4 py-2 border-b border-duck-dark/10 bg-background/60">
<span className="text-sm font-medium text-duck-dark/70">Plans</span>
{plans.length > 0 && (
<select
value={selected ?? ''}
onChange={(ev) => navigate(`/plans/${encodeURIComponent(ev.target.value)}`)}
className="text-xs border border-duck-dark/20 rounded px-2 py-1 bg-background/80 text-duck-dark"
>
{/* Only while nothing is chosen: it disappears once you pick, so it can never be picked back. */}
{!selected && <option value="">Select a plan</option>}
{plans.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
)}
</div>
{/* Markdown content */}
<div className="overflow-y-auto h-full p-6">
<div className="overflow-y-auto h-full p-6">
{selected ? (
<div className="prose prose-sm dark:prose-invert max-w-none prose-headings:text-duck-dark prose-a:text-duck-teal prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none prose-td:text-sm prose-th:text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{content}
</ReactMarkdown>
</div>
</div>
</Card>
) : (
<p className="text-sm text-duck-dark/50">
{plans.length === 0 ? 'No plans yet.' : 'Pick a plan to read it.'}
</p>
)}
</div>
</Card>
</div>
);
};
+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);