harden sharing + dashboard for public launch

Security:
- pairing: cap PIN attempts (close window after 5 wrong guesses) so a
  6-digit PIN cannot be brute-forced within the TTL on a 0.0.0.0 listener.
- web dashboard: reject non-loopback Host (defeats DNS rebinding that
  could read unsanitized local usage) and cross-origin requests (CSRF);
  require application/json on the pair endpoint.
- store tokens with 0600 perms (was world-readable), 0700 dir.

Robustness:
- client: per-request timeout so a hung/asleep peer cannot hang a pull;
  pullDevices fetches remotes concurrently and isolates failures.
- dashboard: normalize peer payloads at the boundary and add an error
  boundary so a peer on a different version cannot white-screen the SPA;
  finite-guard fmtNum/compactUsd.

Tests: PIN attempt-cap test added (1269 pass).
This commit is contained in:
AgentSeal 2026-06-20 17:51:57 +02:00
parent 33c9771b5f
commit d44c46c591
12 changed files with 154 additions and 33 deletions

View file

@ -353,7 +353,7 @@ export function App() {
const showCombined = multi && view === 'all'
const viewTitle = showCombined ? 'All devices' : (primary ? primary.name + (primary.local ? ' · this Mac' : '') : 'Loading…')
const label = local?.payload?.current.label ?? ''
const label = local?.payload?.current?.label ?? ''
return (
<div className="min-h-screen bg-outer-background p-2.5">

View file

@ -131,7 +131,8 @@ export function UsageChart({ daily, unit = 'cost' }: { daily: DailyEntry[]; unit
export function DeviceUsageChart({ devices, unit = 'cost' }: { devices: DeviceUsage[]; unit?: Unit }) {
const { rows, series, labels } = useMemo(() => {
const named = devices.filter((d) => d.payload)
const dates = [...new Set(named.flatMap((d) => d.payload!.history.daily.map((e) => e.date)))].sort((a, b) => a.localeCompare(b))
const dailyOf = (d: DeviceUsage) => d.payload?.history?.daily ?? []
const dates = [...new Set(named.flatMap((d) => dailyOf(d).map((e) => e.date)))].sort((a, b) => a.localeCompare(b))
const series: Series[] = named.map((d, i) => ({
key: `d${i}`,
label: d.name + (d.local ? ' (this Mac)' : ''),
@ -140,7 +141,7 @@ export function DeviceUsageChart({ devices, unit = 'cost' }: { devices: DeviceUs
const rowData = dates.map((date) => {
const row: Record<string, number | string> = { period: date }
named.forEach((d, i) => {
const e = d.payload!.history.daily.find((x) => x.date === date)
const e = dailyOf(d).find((x) => x.date === date)
row[`d${i}`] = e ? (unit === 'tokens' ? e.inputTokens + e.outputTokens : e.cost) : 0
})
return row

View file

@ -62,10 +62,47 @@ export type DeviceUsage = {
error?: string
}
// A device may run a different CodeBurn version and send a payload missing
// fields we treat as required. Fill safe defaults at the boundary so the UI
// can iterate them without crashing (the alternative is a white screen for an
// innocent local user because a peer sent an old shape).
function normalizePayload(p?: Payload): Payload | undefined {
if (!p) return p
const c = (p.current ?? {}) as Partial<Current>
return {
generated: p.generated,
current: {
label: c.label ?? '',
cost: c.cost ?? 0,
calls: c.calls ?? 0,
sessions: c.sessions ?? 0,
oneShotRate: c.oneShotRate ?? null,
inputTokens: c.inputTokens ?? 0,
outputTokens: c.outputTokens ?? 0,
cacheHitPercent: c.cacheHitPercent ?? 0,
codexCredits: c.codexCredits ?? 0,
topActivities: c.topActivities ?? [],
topModels: c.topModels ?? [],
providers: c.providers ?? {},
topProjects: c.topProjects ?? [],
tools: c.tools ?? [],
subagents: c.subagents ?? [],
skills: c.skills ?? [],
mcpServers: c.mcpServers ?? [],
modelEfficiency: c.modelEfficiency ?? [],
localModelSavings: c.localModelSavings ?? { totalUSD: 0 },
retryTax: c.retryTax ?? { totalUSD: 0, retries: 0 },
routingWaste: c.routingWaste ?? { totalSavingsUSD: 0 },
},
history: { daily: p.history?.daily ?? [] },
}
}
export async function fetchDevices(period: Period, provider: string): Promise<{ devices: DeviceUsage[] }> {
const res = await fetch(`/api/devices?period=${encodeURIComponent(period)}&provider=${encodeURIComponent(provider)}`)
if (!res.ok) throw new Error(`Request failed (${res.status})`)
return res.json() as Promise<{ devices: DeviceUsage[] }>
const data = (await res.json()) as { devices: DeviceUsage[] }
return { devices: (data.devices ?? []).map((d) => ({ ...d, payload: normalizePayload(d.payload) })) }
}
export const PERIODS: Array<{ key: Period; label: string }> = [

View file

@ -21,13 +21,17 @@ export function fmtTokens(n: number | undefined | null): string {
}
export function fmtNum(n: number | undefined | null): string {
return (n ?? 0).toLocaleString()
const v = n == null || !isFinite(n) ? 0 : n
return v.toLocaleString()
}
export function compactUsd(n: number): string {
if (n >= 1e6) return '$' + (n / 1e6).toFixed(1) + 'M'
if (n >= 1e3) return '$' + (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + 'k'
return '$' + Math.round(n)
if (!isFinite(n)) return '$0'
const sign = n < 0 ? '-' : ''
const a = Math.abs(n)
if (a >= 1e6) return sign + '$' + (a / 1e6).toFixed(1) + 'M'
if (a >= 1e3) return sign + '$' + (a / 1e3).toFixed(a >= 1e4 ? 0 : 1) + 'k'
return sign + '$' + Math.round(a)
}
// Forest green -> gold -> terracotta ramp for stacked series (mirrors the

View file

@ -1,18 +1,47 @@
import { StrictMode } from 'react'
import { Component, StrictMode, type ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { App } from './App'
import './index.css'
// Last-resort guard: a render error (e.g. an unexpected payload from a peer on
// a different version) shows a recoverable message instead of a blank page.
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state = { error: null as Error | null }
static getDerivedStateFromError(error: Error) {
return { error }
}
render() {
if (this.state.error) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-outer-background p-8 text-center">
<p className="text-sm text-foreground">Something went wrong rendering the dashboard.</p>
<p className="max-w-md text-xs text-tertiary-foreground">{String(this.state.error.message)}</p>
<button
type="button"
onClick={() => location.reload()}
className="rounded-md border border-border bg-card px-3 py-1.5 text-xs text-foreground hover:bg-interactive-secondary"
>
Reload
</button>
</div>
)
}
return this.props.children
}
}
const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 30_000, retry: 1 } },
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>,
)

View file

@ -24,6 +24,7 @@ function call(
path: string,
headers: Record<string, string> = {},
body?: string,
timeoutMs = 8000,
): Promise<Response> {
return new Promise((resolve, reject) => {
const req = request(
@ -54,6 +55,7 @@ function call(
},
)
req.on('error', reject)
req.setTimeout(timeoutMs, () => req.destroy(new Error('peer timed out')))
if (body) req.write(body)
req.end()
})
@ -70,7 +72,9 @@ export function pair(ep: PeerEndpoint, pin: string, name: string): Promise<Respo
// Approve-style pairing: no PIN. The peer prompts its user to approve; this
// request stays open until they accept or decline.
export function pairRequest(ep: PeerEndpoint, name: string): Promise<Response> {
return call(ep, 'POST', '/api/peer/pair-request', {}, JSON.stringify({ name }))
// Stays open while the peer's user decides; give it longer than the server's
// 60s approval prompt.
return call(ep, 'POST', '/api/peer/pair-request', {}, JSON.stringify({ name }), 65_000)
}
export function fetchUsage(ep: PeerEndpoint, token: string, query: UsageQuery = {}): Promise<Response> {

View file

@ -89,17 +89,21 @@ export async function pullDevices(
const identity = await loadOrCreateIdentity(dir)
const remotes = await loadRemotes(dir)
const results: DeviceUsage[] = [{ name: localName, local: true, payload: await localGetUsage(query) }]
for (const r of remotes) {
try {
const res = await fetchUsage({ identity, host: r.host, port: r.port, expectedFingerprint: r.fingerprint }, r.token, query)
if (res.status === 200) results.push({ name: r.name, local: false, payload: res.json as DevicePayload })
else results.push({ name: r.name, local: false, error: res.status === 401 ? 'not authorized (re-pair?)' : `HTTP ${res.status}` })
} catch (e) {
results.push({ name: r.name, local: false, error: e instanceof Error ? e.message : String(e) })
}
}
return results
const local: DeviceUsage = { name: localName, local: true, payload: await localGetUsage(query) }
// Pull every remote concurrently and isolate failures, so one slow or
// powered-off device degrades to an error row instead of blocking the rest.
const remoteResults = await Promise.all(
remotes.map(async (r): Promise<DeviceUsage> => {
try {
const res = await fetchUsage({ identity, host: r.host, port: r.port, expectedFingerprint: r.fingerprint }, r.token, query)
if (res.status === 200) return { name: r.name, local: false, payload: res.json as DevicePayload }
return { name: r.name, local: false, error: res.status === 401 ? 'not authorized (re-pair?)' : `HTTP ${res.status}` }
} catch (e) {
return { name: r.name, local: false, error: e instanceof Error ? e.message : String(e) }
}
}),
)
return [local, ...remoteResults]
}
export function renderDevices(results: DeviceUsage[]): string {

View file

@ -44,23 +44,32 @@ export class PairingWindow {
readonly pin: string
readonly openedAt: number
private used = false
private attempts = 0
constructor(ttlMs = 60_000, now: number = Date.now(), pin: string = generatePin()) {
constructor(ttlMs = 60_000, now: number = Date.now(), pin: string = generatePin(), maxAttempts = 5) {
this.ttlMs = ttlMs
this.pin = pin
this.openedAt = now
this.maxAttempts = maxAttempts
}
private readonly ttlMs: number
private readonly maxAttempts: number
isOpen(now: number = Date.now()): boolean {
return !this.used && now - this.openedAt <= this.ttlMs
}
// Verify a submitted PIN. A correct match consumes the window (one-time use).
// Wrong guesses are counted and the window closes after maxAttempts, so a
// 6-digit PIN cannot be brute-forced by a LAN peer within the TTL.
verify(pin: string, now: number = Date.now()): boolean {
if (!this.isOpen(now)) return false
if (!constantTimeEqual(pin, this.pin)) return false
if (!constantTimeEqual(pin, this.pin)) {
this.attempts += 1
if (this.attempts >= this.maxAttempts) this.used = true
return false
}
this.used = true
return true
}

View file

@ -1,9 +1,11 @@
import type { MenubarPayload } from '../menubar-json.js'
// Strip identifying detail before usage leaves the device. We share aggregate
// numbers (cost, tokens, models, tools, activities, daily) but never project
// names, paths, or per-session detail, so "what you are working on" stays on
// the machine that produced it. Only the totals travel.
// Strip identifying detail before usage leaves the device. We never share
// project names, file paths, or per-session detail (the strongest signal of
// "what you are working on"). We DO share aggregate numbers plus model, tool,
// task, subagent, skill, and MCP-server usage, since the dashboard surfaces
// those per device. If a user names a subagent/skill after a client, that name
// would travel; revisit if that becomes a concern.
export function sanitizeForSharing(payload: MenubarPayload): MenubarPayload {
return {
...payload,

View file

@ -1,4 +1,4 @@
import { readFile, writeFile, mkdir } from 'fs/promises'
import { readFile, writeFile, mkdir, chmod } from 'fs/promises'
import { join, dirname } from 'path'
import { getConfigFilePath } from '../config.js'
@ -28,9 +28,13 @@ async function readJson<T>(path: string, fallback: T): Promise<T> {
}
}
// These files hold bearer tokens, so keep them owner-only (0600) like the TLS
// private key. mkdir/writeFile modes only apply on creation, so chmod enforces
// it on files that already exist from an earlier version.
async function writeJson(path: string, data: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true })
await writeFile(path, JSON.stringify(data, null, 2))
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
await writeFile(path, JSON.stringify(data, null, 2), { mode: 0o600 })
await chmod(path, 0o600).catch(() => {})
}
// Peers allowed to pull from this device (the sharing side, used by ShareServer).

View file

@ -93,6 +93,20 @@ export async function runWebDashboard(opts: {
try {
const url = new URL(req.url ?? '/', 'http://localhost')
// Loopback-only server. Reject any request not addressed to localhost
// (defeats DNS rebinding, which would otherwise let a website you visit
// read your local usage) and any cross-origin request (CSRF). The local
// payload is unsanitized, so this guard is what keeps it on your machine.
const reqHost = (req.headers.host ?? '').replace(/:\d+$/, '')
const loopback = reqHost === '127.0.0.1' || reqHost === 'localhost' || reqHost === '::1' || reqHost === '[::1]'
const origin = req.headers.origin
const originOk = !origin || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(origin)
if (!loopback || !originOk) {
res.writeHead(403, { 'content-type': 'text/plain' })
res.end('Forbidden')
return
}
if (url.pathname === '/api/usage') {
const period = url.searchParams.get('period') ?? opts.period
const provider = url.searchParams.get('provider') ?? opts.provider
@ -166,6 +180,11 @@ export async function runWebDashboard(opts: {
// Pair with a chosen discovered device. Blocks until the other device
// approves (or declines / times out), then stores the link.
if (url.pathname === '/api/devices/pair' && req.method === 'POST') {
if (!(req.headers['content-type'] ?? '').includes('application/json')) {
res.writeHead(415, { 'content-type': 'application/json' })
res.end(JSON.stringify({ ok: false, error: 'content-type must be application/json' }))
return
}
const body = JSON.parse((await readBody(req)) || '{}') as { name: string; host: string; port: number; fingerprint: string }
try {
const device = await linkRemote(body)

View file

@ -66,6 +66,14 @@ describe('PairingWindow', () => {
expect(w.verify('123456', 1100)).toBe(true)
expect(w.verify('123456', 1200)).toBe(false)
})
it('closes after too many wrong guesses (no brute force within the window)', () => {
const w = new PairingWindow(10_000, 1000, '123456', 5)
for (let i = 0; i < 5; i++) expect(w.verify('000000', 1000 + i)).toBe(false)
// window is now locked even though the TTL has not expired
expect(w.isOpen(1100)).toBe(false)
// and the correct PIN no longer works
expect(w.verify('123456', 1100)).toBe(false)
})
})
describe('PeerStore', () => {