Root cause
Injection & unsafe execution introduced
Implement Grackle V0: monorepo scaffold, proto, sidecar, server, CLI, and web UI
How AI contributed: Direct introductionPublished Mar 25, 2026 in
nick-pape/grackleAll 2 findings in this repository
Missing security headers (CWE-693) in the Grackle web handler: createWebHandler (packages/server/src/index.ts) never set Content-Security-Policy, X-Frame-Options or X-Content-Type-Options on any response path, leaving the app exposed to XSS, clickjacking and MIME-sniffing.
Root cause
Implement Grackle V0: monorepo scaffold, proto, sidecar, server, CLI, and web UI
Fix
fix(server): add security headers to web handler (GHSA-3mjm-x6gw-2x42) (#801)
Fix by Nick Pape · no AI marker found
Case facts
@@ -0,0 +1,87 @@+import { useState, type FormEvent } from "react";+import { useGrackle } from "../context/GrackleContext.js";++export function SpawnBar() {+ const { environments, spawn } = useGrackle();+ const [prompt, setPrompt] = useState("");+ const [envId, setEnvId] = useState("");++ const connectedEnvs = environments.filter((e) => e.status === "connected");++ const handleSubmit = (e: FormEvent) => {+ e.preventDefault();+ const targetEnv = envId || connectedEnvs[0]?.id;+ if (!targetEnv || !prompt.trim()) return;+ spawn(targetEnv, prompt);+ setPrompt("");+ };++ return (+ <form+ onSubmit={handleSubmit}+ style={{+ display: "flex",+ alignItems: "center",+ gap: "8px",+ padding: "8px 12px",+ borderTop: "1px solid #0f3460",+ background: "#16213e",+ }}+ >+ <span style={{ fontSize: "12px", color: "#888" }}>New:</span>+ <input+ type="text"+ value={prompt}+ onChange={(e) => setPrompt(e.target.value)}+ placeholder="Enter prompt..."+ style={{+ flex: 1,+ background: "#0f3460",+ border: "1px solid #333",+ color: "#e0e0e0",+ padding: "6px 10px",+ borderRadius: "4px",+ outline: "none",+ fontFamily: "monospace",+ fontSize: "13px",+ }}+ />+ <select+ value={envId}+ onChange={(e) => setEnvId(e.target.value)}+ style={{+ background: "#0f3460",+ border: "1px solid #333",+ color: "#e0e0e0",+ padding: "6px 8px",+ borderRadius: "4px",+ fontFamily: "monospace",+ fontSize: "12px",+ }}+ >+ {connectedEnvs.length === 0 && <option value="">No envs</option>}+ {connectedEnvs.map((env) => (+ <option key={env.id} value={env.id}>+ {env.displayName || env.id}+ </option>+ ))}+ </select>+ <button+ type="submit"+ disabled={connectedEnvs.length === 0 || !prompt.trim()}+ style={{+ background: connectedEnvs.length > 0 && prompt.trim() ? "#4ecca3" : "#333",+ border: "none",+ color: "#1a1a2e",+ padding: "6px 16px",+ borderRadius: "4px",+ cursor: connectedEnvs.length > 0 ? "pointer" : "not-allowed",+ fontWeight: "bold",+ fontFamily: "monospace",+ }}+ >+ Go+ </button>+ </form>+ );+}@@ -0,0 +1,25 @@+import { writeFile, mkdir } from "node:fs/promises";+import { dirname } from "node:path";++const envTokens = new Map<string, string>();++export function getEnvTokens(): Map<string, string> {+ return envTokens;+}++export async function writeTokens(+ tokens: Array<{ name: string; type: string; envVar: string; filePath: string; value: string }>+): Promise<void> {+ for (const token of tokens) {+ if (token.type === "env_var" && token.envVar) {+ process.env[token.envVar] = token.value;+ envTokens.set(token.envVar, token.value);+ console.log(`[token-writer] Set env var ${token.envVar}`);+ } else if (token.type === "file" && token.filePath) {+ const resolvedPath = token.filePath.replace(/^~/, process.env.HOME || "");+ await mkdir(dirname(resolvedPath), { recursive: true });+ await writeFile(resolvedPath, token.value, { mode: 0o600 });+ console.log(`[token-writer] Wrote file ${resolvedPath}`);+ }+ }+}@@ -0,0 +1,87 @@+import { EventEmitter } from "node:events";+import type { AgentRuntime, AgentSession, AgentEvent, SpawnOpts, ResumeOpts } from "./runtime.js";+import type { SessionStatus } from "@grackle/common";++class StubSession implements AgentSession {+ id: string;+ runtimeSessionId: string;+ status: SessionStatus = "running";++ private emitter = new EventEmitter();+ private inputResolve: ((text: string) => void) | null = null;+ private killed = false;++ constructor(id: string, private prompt: string) {+ this.id = id;+ this.runtimeSessionId = `stub-${id}`;+ }++ async *stream(): AsyncIterable<AgentEvent> {+ const ts = () => new Date().toISOString();++ yield { type: "system", timestamp: ts(), content: "Stub runtime initialized" };+ yield { type: "text", timestamp: ts(), content: `Echo: ${this.prompt}` };++ if (this.killed) return;++ yield {+ type: "tool_use",+ timestamp: ts(),+ content: JSON.stringify({ tool: "echo", args: { message: this.prompt } }),+ };++ yield {+ type: "tool_result",+ timestamp: ts(),+ content: `Tool output: "${this.prompt}"`,+ };++ if (this.killed) return;++ // Wait for user input+ this.status = "waiting_input";+ yield { type: "status", timestamp: ts(), content: "waiting_input" };++ const input = await this.waitForInput();+ if (this.killed) return;++ this.status = "running";+ yield { type: "status", timestamp: ts(), content: "running" };+ yield { type: "text", timestamp: ts(), content: `You said: ${input}` };++ // Complete+ this.status = "completed";+ yield { type: "status", timestamp: ts(), content: "completed" };+ }++ private waitForInput(): Promise<string> {+ return new Promise<string>((resolve) => {+ this.inputResolve = resolve;+ this.emitter.once("input", resolve);+ });+ }++ sendInput(text: string): void {+ this.emitter.emit("input", text);+ }++ kill(): void {+ this.killed = true;+ this.status = "killed";+ if (this.inputResolve) {+ this.inputResolve("");+ }+ }+}++export class StubRuntime implements AgentRuntime {+ name = "stub";++ spawn(opts: SpawnOpts): AgentSession {+ return new StubSession(opts.sessionId, opts.prompt);+ }++ resume(opts: ResumeOpts): AgentSession {+ return new StubSession(opts.sessionId, "(resumed session)");+ }+}@@ -0,0 +1,33 @@+import type { ServerResponse } from "node:http";++/**+ * Content Security Policy for the web handler.+ *+ * Covers the React SPA (served from 'self') and server-rendered pages+ * (pairing/authorize) which use inline styles.+ */+export const WEB_CONTENT_SECURITY_POLICY: string = [+ "default-src 'self'",+ "script-src 'self'",+ "style-src 'self' 'unsafe-inline'",+ "img-src 'self' data:",+ "font-src 'self'",+ "connect-src 'self'",+ "object-src 'none'",+ "form-action 'self'",+ "frame-ancestors 'none'",+ "base-uri 'self'",+].join("; ");++/**+ * Set defense-in-depth security headers on every web response.+ *+ * Called at the top of `createWebHandler`'s returned function so that all+ * response paths (static files, HTML pages, JSON APIs, redirects) are covered+ * without modifying each `writeHead` call individually.+ */+export function setSecurityHeaders(res: ServerResponse): void {+ res.setHeader("X-Content-Type-Options", "nosniff");+ res.setHeader("X-Frame-Options", "DENY");+ res.setHeader("Content-Security-Policy", WEB_CONTENT_SECURITY_POLICY);+}@@ -0,0 +1,145 @@+/**+ * Tests for security headers (GHSA-3mjm-x6gw-2x42).+ *+ * Verifies that setSecurityHeaders sets Content-Security-Policy,+ * X-Frame-Options, and X-Content-Type-Options on every response.+ */+import { describe, it, expect } from "vitest";+import http from "node:http";+import { setSecurityHeaders, WEB_CONTENT_SECURITY_POLICY } from "./security-headers.js";++/**+ * Make an HTTP request and return response details.+ * Does NOT follow redirects — returns the redirect response itself.+ */+function request(+ server: http.Server,+ path: string,+): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {+ return new Promise((resolve, reject) => {+ const addr = server.address() as { port: number };+ const req = http.request(+ { hostname: "127.0.0.1", port: addr.port, path },+ (res) => {+ let body = "";+ res.on("data", (chunk: Buffer) => { body += chunk.toString(); });+ res.on("end", () => resolve({ status: res.statusCode!, headers: res.headers, body }));+ },+ );+ req.on("error", reject);+ req.end();+ });+}++describe("security headers", () => {+ describe("setSecurityHeaders", () => {+ it("sets all three headers on a 200 HTML response", async () => {+ const server = http.createServer((req, res) => {+ setSecurityHeaders(res);+ res.writeHead(200, { "Content-Type": "text/html" });+ res.end("<html></html>");+ });++ await new Promise<void>((resolve) => {+ server.listen(0, "127.0.0.1", resolve);+ });++ try {+ const resp = await request(server, "/");+ expect(resp.headers["content-security-policy"]).toBe(WEB_CONTENT_SECURITY_POLICY);+ expect(resp.headers["x-frame-options"]).toBe("DENY");+ expect(resp.headers["x-content-type-options"]).toBe("nosniff");+ } finally {+ await new Promise<void>((resolve) => { server.close(() => resolve()); });+ }+ });++ it("sets headers on a JSON response", async () => {+ const server = http.createServer((req, res) => {+ setSecurityHeaders(res);+ res.writeHead(200, { "Content-Type": "application/json" });+ res.end(JSON.stringify({ ok: true }));+ });++ await new Promise<void>((resolve) => {+ server.listen(0, "127.0.0.1", resolve);+ });++ try {+ const resp = await request(server, "/.well-known/oauth-authorization-server");+ expect(resp.headers["content-security-policy"]).toBe(WEB_CONTENT_SECURITY_POLICY);+ expect(resp.headers["x-frame-options"]).toBe("DENY");+ expect(resp.headers["x-content-type-options"]).toBe("nosniff");+ } finally {+ await new Promise<void>((resolve) => { server.close(() => resolve()); });+ }+ });++ it("sets headers on a 302 redirect", async () => {+ const server = http.createServer((req, res) => {+ setSecurityHeaders(res);+ res.writeHead(302, { Location: "/" });+ res.end();+ });++ await new Promise<void>((resolve) => {+ server.listen(0, "127.0.0.1", resolve);+ });++ try {@@ -28,6 +28,7 @@ import { readFileSync, existsSync } from "node:fs"; import { join, dirname, extname, normalize, resolve, relative } from "node:path"; import { createRequire } from "node:module"; import { loadOrCreateApiKey, verifyApiKey } from "./api-key.js";+import { setSecurityHeaders } from "./security-headers.js"; import { createSession, validateSessionCookie } from "./session.js"; import { startSessionCleanup, stopSessionCleanup } from "./session.js"; import { generatePairingCode, redeemPairingCode, startPairingCleanup, stopPairingCleanup } from "./pairing.js";@@ -271,6 +272,8 @@ function createWebHandler( // eslint-disable-next-line @typescript-eslint/no-misused-promises return async (req: http.IncomingMessage, res: http.ServerResponse) => {+ setSecurityHeaders(res);+ let rawPath: string; let queryString = ""; try {AI-assisted change ce1b78a272bcd2bc6d09ee078bab95f9014e93285bb9d2618b071b9e24476f07 · Fix 20f58b1aca8abc3ced6fd82c2cd1eea9a3e20dc5e54c0441570753c1ae6c2c53