Files
Auto-Trader/frontend/src/hooks/useSSE.ts
T
Haozhe Wu 4e3c91c77a security: address all 10 findings from external audit (issue #476)
Implements VT-001 through VT-010 from the 2026-07-10 external security
audit (Trivy/Semgrep/ZAP/Gitleaks/pip-audit against a real deployment):

- VT-001 (Critical): AST hardening in backtest/runner.py rejects
  network/subprocess/eval/os.environ/unsafe-open calls reachable from
  generated SignalEngine method bodies (previously only checked
  top-level statements). Defense-in-depth: ephemeral per-run HOME,
  RLIMIT_AS/NOFILE caps, best-effort UID-drop to a new vibe-sandbox
  user (currently inert under Docker's cap model - see session doc;
  accepted as documented residual gap, primary AST defense unaffected).
- VT-002: multi-stage Dockerfile split so build-essential never
  reaches the runtime image.
- VT-003: SSE auth moved from a long-lived API key in the URL query
  string to short-lived single-use tickets; access-log redaction for
  any remaining query secrets.
- VT-004: agent/.env split-brain fixed (bind mount + atomic 0600
  writes) between docker-compose and Web UI settings writes.
- VT-005: /correlation now requires auth + per-IP rate limit; generic
  exceptions no longer leak raw text to clients.
- VT-006: CSP-Report-Only + security headers middleware; Google Fonts
  replaced with self-hosted @fontsource packages.
- VT-007: docker-compose hardening (cap_drop ALL + minimal cap_add,
  no-new-privileges, read_only rootfs, resource limits).
- VT-008: /health split into /live (liveness) and /ready (real
  provider-config readiness check); /health kept as a compat alias.
- VT-009: base images pinned by digest, GitHub Actions pinned by
  commit SHA, dependabot.yml added, hash-verified requirements-lock.txt
  now actually wired into the Docker build via --require-hashes.
- VT-010: universe-panel pickle cache sidecar upgraded from an
  unkeyed SHA-256 (forgeable by anyone with cache-dir write access)
  to a keyed HMAC.

Implemented via a 5-parcel Workflow (file-disjoint, independent
adversarial verification per parcel, then a holistic final audit).
The final audit caught a real regression (CI env-var-gate violation)
and confirmed a dependency-pinning gap, both fixed and re-verified
here, including a latent transitive-dependency bug the fix surfaced
(smartmoneyconcepts<0.0.27 pulls an unbuildable zigzag package) - see
docs/sessions/2026-07-13_session_vt476_security_hardening.md (local,
gitignored) for full evidence trail.

Full suite: 5191 passed, 6 pre-existing/environmental failures
(unchanged from main). Docker build + hardened compose boot verified
end-to-end.
2026-07-13 01:40:53 +08:00

196 lines
6.0 KiB
TypeScript

/**
* SSE Hook — auto-reconnect + exponential backoff + LRU dedup + Last-Event-ID resume.
*/
import { useCallback, useRef } from "react";
import { getApiAuthKey, withAuthTicket } from "@/lib/apiAuth";
type EventHandler = (data: Record<string, unknown>) => void;
type Handlers = Record<string, EventHandler>;
export type SSEStatus = "disconnected" | "connected" | "reconnecting";
interface SSEConfig {
initialRetryMs?: number;
maxRetryMs?: number;
backoffFactor?: number;
dedupeCapacity?: number;
}
const DEFAULTS: Required<SSEConfig> = {
initialRetryMs: 1000,
maxRetryMs: 30000,
backoffFactor: 2,
dedupeCapacity: 500,
};
export function useSSE(config?: SSEConfig) {
const opts = { ...DEFAULTS, ...config };
const sourceRef = useRef<EventSource | null>(null);
const handlersRef = useRef<Handlers>({});
const urlRef = useRef<string>("");
const closedRef = useRef(true);
const retryCountRef = useRef(0);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastEventIdRef = useRef<string | null>(null);
const statusRef = useRef<SSEStatus>("disconnected");
const onStatusChangeRef = useRef<((s: SSEStatus) => void) | null>(null);
// LRU dedup set
const seenIdsRef = useRef<Set<string>>(new Set());
const seenOrderRef = useRef<string[]>([]);
const trackEventId = useCallback((eventId: string): boolean => {
if (!eventId) return false;
const seen = seenIdsRef.current;
const order = seenOrderRef.current;
if (seen.has(eventId)) return true; // duplicate
seen.add(eventId);
order.push(eventId);
if (order.length > opts.dedupeCapacity) {
const oldest = order.shift()!;
seen.delete(oldest);
}
return false;
}, [opts.dedupeCapacity]);
const setStatus = useCallback((s: SSEStatus) => {
statusRef.current = s;
onStatusChangeRef.current?.(s);
}, []);
const buildUrl = useCallback((baseUrl: string) => {
const sep = baseUrl.includes("?") ? "&" : "?";
if (lastEventIdRef.current) {
return `${baseUrl}${sep}Last-Event-ID=${encodeURIComponent(lastEventIdRef.current)}`;
}
return baseUrl;
}, []);
const attach = useCallback((url: string) => {
if (closedRef.current) return;
const source = new EventSource(url);
sourceRef.current = source;
source.onopen = () => {
retryCountRef.current = 0;
setStatus("connected");
};
// Only subscribe to event types the backend actually emits
const knownTypes = [
"text_delta", "reasoning_delta", "stream_reset", "thinking_done", "tool_call", "tool_result", "compact",
"tool_heartbeat", "tool_progress", "llm_usage",
"swarm.started", "swarm.event",
"attempt.created", "attempt.started", "attempt.completed", "attempt.failed",
"message.received", "session.created",
"goal.created", "goal.evidence", "goal.updated",
"mandate.proposal", "mandate.committed", "live.halted", "live.resumed", "live.action",
"heartbeat", "done",
];
const handleRaw = (eventType: string, raw: MessageEvent) => {
if (raw.lastEventId) {
lastEventIdRef.current = raw.lastEventId;
}
if (raw.lastEventId && trackEventId(raw.lastEventId)) return;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw.data);
} catch {
parsed = { raw: raw.data };
}
const handler = handlersRef.current[eventType] ?? handlersRef.current["message"];
handler?.(parsed);
};
for (const eventType of knownTypes) {
source.addEventListener(eventType, (e) => handleRaw(eventType, e as MessageEvent));
}
source.onerror = () => {
if (closedRef.current) return;
source.close();
sourceRef.current = null;
scheduleReconnect();
};
}, [trackEventId, setStatus]);
const doConnect = useCallback(() => {
if (closedRef.current) return;
const baseUrl = buildUrl(urlRef.current);
// When an API key is stored we must first mint a single-use SSE ticket —
// EventSource can't send an Authorization header. In loopback dev mode (no
// key) the backend bypasses auth, so we connect synchronously and preserve
// the original zero-round-trip behavior (and the synchronous test path).
if (!getApiAuthKey()) {
attach(baseUrl);
return;
}
withAuthTicket(baseUrl)
.then(attach)
.catch(() => {
if (!closedRef.current) scheduleReconnect();
});
}, [buildUrl, attach]);
const scheduleReconnect = useCallback(() => {
if (closedRef.current) return;
retryCountRef.current += 1;
const delay = Math.min(
opts.initialRetryMs * Math.pow(opts.backoffFactor, retryCountRef.current - 1),
opts.maxRetryMs,
);
setStatus("reconnecting");
handlersRef.current["reconnect"]?.({ attempt: retryCountRef.current, delayMs: delay });
retryTimerRef.current = setTimeout(() => {
retryTimerRef.current = null;
doConnect();
}, delay);
}, [opts.initialRetryMs, opts.backoffFactor, opts.maxRetryMs, setStatus, doConnect]);
const connect = useCallback((url: string, handlers: Handlers) => {
closedRef.current = true;
sourceRef.current?.close();
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
urlRef.current = url;
handlersRef.current = handlers;
closedRef.current = false;
retryCountRef.current = 0;
lastEventIdRef.current = null;
seenIdsRef.current.clear();
seenOrderRef.current.length = 0;
doConnect();
}, [doConnect]);
const disconnect = useCallback(() => {
closedRef.current = true;
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
sourceRef.current?.close();
sourceRef.current = null;
setStatus("disconnected");
}, [setStatus]);
const getStatus = useCallback(() => statusRef.current, []);
const onStatusChange = useCallback((cb: (s: SSEStatus) => void) => {
onStatusChangeRef.current = cb;
}, []);
return { connect, disconnect, getStatus, onStatusChange };
}