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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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