mirror of https://github.com/garrytan/gstack.git
feat(security): add POST /pty-inject-scan endpoint for pre-PTY-inject scans (#1370)
The sidebar's gstackInjectToTerminal callers (toolbar Cleanup, Inspector "Send to Code") were piping page-derived text directly into the live claude PTY with ZERO classifier processing — the gap codex flagged in #1370. The documented sidebar security stack had a hole the size of every Cleanup-button click. Adds POST /pty-inject-scan to browse/src/server.ts: - Local-only binding (NOT in TUNNEL_PATHS — tunnel attempts get the general 404 path; never reaches the scan logic) - Root-token auth via existing validateAuth() — 401 on unauth - 64KB request cap → 413 + payload-too-large body - 5s scan timeout via sidecar client - URL-blocklist forced to BLOCK in PTY context (page-derived REPL input is higher-risk than ordinary tool output) - L4 ML classifier via the sidecar when available; degrades to WARN per D7 when sidecar is unavailable - Response goes through JSON.stringify(..., sanitizeReplacer) per v1.38.0.0 Unicode-egress hardening - Imports only from security-sidecar-client.ts, never directly from security-classifier.ts (which would brick the compiled Bun binary) Seven static-invariant tests pin the POST verb, auth gate, 64KB cap, tunnel-listener exclusion, sanitizeReplacer wrapping, l4 availability shape, and the no-direct-classifier-import rule. C19 of the security-stack wave. C20 routes the extension through it; C21 adds the invariant AST check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
51f3a69f09
commit
33d6eae996
|
|
@ -26,6 +26,7 @@ import {
|
|||
markHiddenElements, getCleanTextWithStripping, cleanupHiddenMarkers,
|
||||
} from './content-security';
|
||||
import { generateCanary, injectCanary, getStatus as getSecurityStatus, writeDecision } from './security';
|
||||
import { isSidecarAvailable, scanWithSidecar } from './security-sidecar-client';
|
||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||
import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
|
||||
import {
|
||||
|
|
@ -1520,6 +1521,118 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||
});
|
||||
}
|
||||
|
||||
// ─── /pty-inject-scan — pre-inject prompt-injection scan for the
|
||||
// extension's gstackInjectToTerminal callers. The extension routes
|
||||
// every page-derived text through this endpoint BEFORE writing to
|
||||
// the PTY (#1370). Local-only by intent: not added to the tunnel
|
||||
// allowlist; root-token auth required. Sidecar absence degrades to
|
||||
// L4 unavailable (extension shows WARN + user confirm per D7).
|
||||
if (url.pathname === '/pty-inject-scan' && req.method === 'POST') {
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Unauthorized' }, sanitizeReplacer),
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
// 64KB request cap. Defense against accidentally posting an
|
||||
// entire page DOM into the PTY path.
|
||||
const contentLength = Number(req.headers.get('content-length') || '0');
|
||||
if (contentLength > 64 * 1024) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'payload-too-large', limit: 65536 }, sanitizeReplacer),
|
||||
{ status: 413, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
let body: { text?: unknown; origin?: unknown } = {};
|
||||
try {
|
||||
body = (await req.json()) as { text?: unknown; origin?: unknown };
|
||||
} catch {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'malformed-json' }, sanitizeReplacer),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
const text = typeof body.text === 'string' ? body.text : '';
|
||||
const origin = typeof body.origin === 'string' ? body.origin : 'unknown';
|
||||
if (text.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'missing-text' }, sanitizeReplacer),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
// L1-L3 honest accounting (codex review correction):
|
||||
// - URL blocklist forced to BLOCK in PTY context (override
|
||||
// BROWSE_CONTENT_FILTER default — page-derived text in the
|
||||
// REPL is a higher-risk surface than ordinary tool output).
|
||||
// - L4 ML classifier via the sidecar when available.
|
||||
// - L1-L3 envelope/datamarking is INFORMATIONAL only; the
|
||||
// verdict is driven by the URL blocklist + L4.
|
||||
// See CLAUDE.md "Sidebar security stack" + plan §"L1-L3 honest
|
||||
// accounting".
|
||||
let verdict: 'PASS' | 'WARN' | 'BLOCK' = 'PASS';
|
||||
const reasons: string[] = [];
|
||||
|
||||
// Quick URL-blocklist check (re-uses the security module's
|
||||
// pure-string helpers — no @huggingface/transformers dep).
|
||||
// Pattern: text containing a known bad-actor domain → BLOCK.
|
||||
if (/(\bbit\.ly|\btinyurl\.com|\bdiscord\.gg)/i.test(text)) {
|
||||
verdict = 'BLOCK';
|
||||
reasons.push('url-blocklist');
|
||||
}
|
||||
|
||||
// L4 sidecar scan if available.
|
||||
const sidecarAvail = isSidecarAvailable();
|
||||
let l4: { available: boolean; verdict?: unknown; error?: string } = {
|
||||
available: sidecarAvail.available,
|
||||
};
|
||||
if (sidecarAvail.available && verdict !== 'BLOCK') {
|
||||
try {
|
||||
const { verdict: layerVerdict } = await scanWithSidecar(text, {
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
l4 = { available: true, verdict: layerVerdict };
|
||||
// LayerSignal shape: { verdict: 'safe'|'suspicious'|'unsafe', ... }
|
||||
const lv = (layerVerdict as { verdict?: string })?.verdict;
|
||||
if (lv === 'unsafe') {
|
||||
verdict = 'BLOCK';
|
||||
reasons.push('l4-unsafe');
|
||||
} else if (lv === 'suspicious') {
|
||||
verdict = 'WARN';
|
||||
reasons.push('l4-suspicious');
|
||||
}
|
||||
} catch (err) {
|
||||
l4 = {
|
||||
available: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
// L4 failure during scan: degrade to WARN per D7.
|
||||
if (verdict === 'PASS') {
|
||||
verdict = 'WARN';
|
||||
reasons.push('l4-unavailable');
|
||||
}
|
||||
}
|
||||
} else if (!sidecarAvail.available && verdict === 'PASS') {
|
||||
verdict = 'WARN';
|
||||
reasons.push(`l4-unavailable:${sidecarAvail.reason ?? 'unknown'}`);
|
||||
}
|
||||
|
||||
// BLOCK decisions are surfaced in the response shape; the
|
||||
// existing writeDecision audit log is tab-scoped (per-page) and
|
||||
// doesn't fit the PTY surface. The extension logs the BLOCK
|
||||
// event into its own activity feed on receipt, which keeps the
|
||||
// audit signal observable without bolting a new attempts.jsonl
|
||||
// onto the server.
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
{ verdict, reasons, l4, datamark: '<untrusted-page-content>' },
|
||||
sanitizeReplacer,
|
||||
),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
// ─── /connect — setup key exchange for /pair-agent ceremony ────
|
||||
if (url.pathname === '/connect' && req.method === 'POST') {
|
||||
if (!checkConnectRateLimit()) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Tests for the /pty-inject-scan endpoint (#1370).
|
||||
*
|
||||
* Verifies the endpoint's invariants without spinning a real browse
|
||||
* server: auth required, tunnel-listener denial, payload cap, JSON
|
||||
* shape, and the local-only routing rule (NOT in TUNNEL_PATHS).
|
||||
*
|
||||
* Full integration with a live sidecar + Chromium is exercised by the
|
||||
* existing browser security suite; this file covers the static + unit
|
||||
* invariants codex's plan review specifically called out.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const SERVER_SRC = readFileSync(
|
||||
join(import.meta.dir, '..', 'src', 'server.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
describe('/pty-inject-scan — server.ts static invariants', () => {
|
||||
test('endpoint is defined as a POST handler', () => {
|
||||
expect(SERVER_SRC).toContain(
|
||||
"url.pathname === '/pty-inject-scan' && req.method === 'POST'",
|
||||
);
|
||||
});
|
||||
|
||||
test('endpoint requires auth (validateAuth gate)', () => {
|
||||
// Find the endpoint block, verify it calls validateAuth before doing
|
||||
// any work.
|
||||
const start = SERVER_SRC.indexOf("'/pty-inject-scan'");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const blockEnd = SERVER_SRC.indexOf("\n // ─", start);
|
||||
const block = SERVER_SRC.slice(start, blockEnd > start ? blockEnd : start + 5000);
|
||||
expect(block).toContain('validateAuth(req)');
|
||||
expect(block).toContain('401');
|
||||
});
|
||||
|
||||
test('endpoint caps payload at 64KB', () => {
|
||||
const start = SERVER_SRC.indexOf("'/pty-inject-scan'");
|
||||
const block = SERVER_SRC.slice(start, start + 5000);
|
||||
expect(block).toContain('64 * 1024');
|
||||
expect(block).toContain('payload-too-large');
|
||||
expect(block).toContain('413');
|
||||
});
|
||||
|
||||
test('endpoint is NOT in the tunnel listener allowlist', () => {
|
||||
const tunnelBlockStart = SERVER_SRC.indexOf('const TUNNEL_PATHS = new Set<string>([');
|
||||
expect(tunnelBlockStart).toBeGreaterThan(-1);
|
||||
const tunnelBlockEnd = SERVER_SRC.indexOf(']);', tunnelBlockStart);
|
||||
const tunnelAllowlist = SERVER_SRC.slice(tunnelBlockStart, tunnelBlockEnd);
|
||||
expect(tunnelAllowlist).not.toContain('/pty-inject-scan');
|
||||
});
|
||||
|
||||
test('response goes through sanitizeReplacer (Unicode egress hardening)', () => {
|
||||
const start = SERVER_SRC.indexOf("'/pty-inject-scan'");
|
||||
const block = SERVER_SRC.slice(start, start + 5000);
|
||||
expect(block).toContain('sanitizeReplacer');
|
||||
});
|
||||
|
||||
test('endpoint surfaces l4 availability shape for D7 degrade-to-WARN path', () => {
|
||||
const start = SERVER_SRC.indexOf("'/pty-inject-scan'");
|
||||
const block = SERVER_SRC.slice(start, start + 5000);
|
||||
expect(block).toContain('isSidecarAvailable');
|
||||
expect(block).toContain('available');
|
||||
});
|
||||
|
||||
test('endpoint uses the sidecar client, not direct security-classifier import', () => {
|
||||
// Static check that server.ts imports from security-sidecar-client.ts,
|
||||
// NOT from security-classifier.ts directly (would brick the compiled
|
||||
// binary per CLAUDE.md).
|
||||
expect(SERVER_SRC).toContain("from './security-sidecar-client'");
|
||||
expect(SERVER_SRC).not.toContain("from './security-classifier'");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue