diff --git a/cmd/vscode-bridge/main.go b/cmd/vscode-bridge/main.go index 5f35ba0..362383a 100644 --- a/cmd/vscode-bridge/main.go +++ b/cmd/vscode-bridge/main.go @@ -47,6 +47,14 @@ type runtimeEventRequest struct { Payload map[string]any `json:"payload"` } +const ( + bridgeReadHeaderTimeout = 5 * time.Second + bridgeReadTimeout = 15 * time.Second + bridgeIdleTimeout = 60 * time.Second + bridgeMaxHeaderBytes = 16 << 10 + bridgeMaxJSONBodyBytes = 1 << 20 +) + type eventBroker struct { mu sync.Mutex clients map[chan []byte]struct{} @@ -115,7 +123,7 @@ func main() { mux.HandleFunc("/events", bridge.auth(token, bridge.handleEvents)) mux.HandleFunc("/runtime/event", bridge.auth(token, bridge.handleRuntimeEvent)) - server := &http.Server{Handler: mux} + server := newBridgeHTTPServer(mux) listener, err := net.Listen("tcp", *listenAddr) if err != nil { log.Fatalf("listen: %v", err) @@ -207,7 +215,7 @@ func (a *bridgeApp) handleRPC(w http.ResponseWriter, r *http.Request) { return } var req rpcRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := decodeLimitedJSON(w, r, &req); err != nil { writeJSON(w, rpcResponse{Error: err.Error()}) return } @@ -225,7 +233,7 @@ func (a *bridgeApp) handleRuntimeEvent(w http.ResponseWriter, r *http.Request) { return } var req runtimeEventRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := decodeLimitedJSON(w, r, &req); err != nil { writeJSON(w, rpcResponse{Error: err.Error()}) return } @@ -325,6 +333,21 @@ func randomToken() (string, error) { return hex.EncodeToString(b[:]), nil } +func newBridgeHTTPServer(handler http.Handler) *http.Server { + return &http.Server{ + Handler: handler, + ReadHeaderTimeout: bridgeReadHeaderTimeout, + ReadTimeout: bridgeReadTimeout, + IdleTimeout: bridgeIdleTimeout, + MaxHeaderBytes: bridgeMaxHeaderBytes, + } +} + +func decodeLimitedJSON(w http.ResponseWriter, r *http.Request, dst any) error { + r.Body = http.MaxBytesReader(w, r.Body, bridgeMaxJSONBodyBytes) + return json.NewDecoder(r.Body).Decode(dst) +} + func writeJSON(w http.ResponseWriter, value any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(value) diff --git a/cmd/vscode-bridge/main_test.go b/cmd/vscode-bridge/main_test.go new file mode 100644 index 0000000..ca3dbaf --- /dev/null +++ b/cmd/vscode-bridge/main_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNewBridgeHTTPServerSetsResourceLimits(t *testing.T) { + server := newBridgeHTTPServer(http.NewServeMux()) + + if server.ReadHeaderTimeout != bridgeReadHeaderTimeout { + t.Fatalf("ReadHeaderTimeout = %s, want %s", server.ReadHeaderTimeout, bridgeReadHeaderTimeout) + } + if server.ReadTimeout != bridgeReadTimeout { + t.Fatalf("ReadTimeout = %s, want %s", server.ReadTimeout, bridgeReadTimeout) + } + if server.IdleTimeout != bridgeIdleTimeout { + t.Fatalf("IdleTimeout = %s, want %s", server.IdleTimeout, bridgeIdleTimeout) + } + if server.MaxHeaderBytes != bridgeMaxHeaderBytes { + t.Fatalf("MaxHeaderBytes = %d, want %d", server.MaxHeaderBytes, bridgeMaxHeaderBytes) + } +} + +func TestHandleRPCRejectsOversizedJSONBody(t *testing.T) { + app := &bridgeApp{} + largeBody := `{"method":"ListProfiles","args":[],"pad":"` + strings.Repeat("x", bridgeMaxJSONBodyBytes) + `"}` + req := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader(largeBody)) + rec := httptest.NewRecorder() + + app.handleRPC(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var resp rpcResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("response is not JSON: %v", err) + } + if !strings.Contains(resp.Error, "request body too large") { + t.Fatalf("error = %q, want request body limit error", resp.Error) + } +} + +func TestHandleRuntimeEventRejectsOversizedJSONBody(t *testing.T) { + app := &bridgeApp{} + largeBody := `{"name":"session:secret:response","payload":{"pad":"` + strings.Repeat("x", bridgeMaxJSONBodyBytes) + `"}}` + req := httptest.NewRequest(http.MethodPost, "/runtime/event", strings.NewReader(largeBody)) + rec := httptest.NewRecorder() + + app.handleRuntimeEvent(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var resp rpcResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("response is not JSON: %v", err) + } + if !strings.Contains(resp.Error, "request body too large") { + t.Fatalf("error = %q, want request body limit error", resp.Error) + } +} diff --git a/frontend/scripts/test-regressions.js b/frontend/scripts/test-regressions.js index cf7a2ca..0d19032 100644 --- a/frontend/scripts/test-regressions.js +++ b/frontend/scripts/test-regressions.js @@ -219,6 +219,21 @@ assertContains( "VNC Connections", "in-app help must document VNC connection profiles", ); +assertContains( + "vscode/nexus/extension.js", + "crypto.randomBytes(16).toString('base64')", + "Nexus VS Code webview CSP nonces must use cryptographic randomness", +); +assertContains( + "vscode/leviathan/extension.js", + "crypto.randomBytes(16).toString('base64')", + "Leviathan VS Code webview CSP nonces must use cryptographic randomness", +); +assertTreeNotContains( + "vscode", + "const nonce = String(Date.now());", + "VS Code webview CSP nonces must not be predictable timestamps", +); assertContains( "docs/user-manual.md", "## 11. Leviathan binary workbench", diff --git a/vscode/leviathan/extension.js b/vscode/leviathan/extension.js index 84e5622..69365c6 100644 --- a/vscode/leviathan/extension.js +++ b/vscode/leviathan/extension.js @@ -2,10 +2,15 @@ const vscode = require('vscode'); const cp = require('child_process'); +const crypto = require('crypto'); const fs = require('fs'); const http = require('http'); const path = require('path'); +function createCSPNonce() { + return crypto.randomBytes(16).toString('base64'); +} + const TOOLS = [ ['Editor', 'hex', 'Hex Editor', 'Browse and patch bytes in the working copy.'], ['Triage', 'overview', 'Overview', 'Fast first-pass summary across hashes, strings, entropy and IOCs.'], @@ -181,7 +186,7 @@ class ToolsView { } html(webview) { - const nonce = String(Date.now()); + const nonce = createCSPNonce(); const toolsJson = JSON.stringify(TOOLS); return ` @@ -489,7 +494,7 @@ class ToolPanel { } hexHtml(meta) { - const nonce = String(Date.now()); + const nonce = createCSPNonce(); const initialFileState = JSON.stringify(this.fileState || null); const name = escapeHtml(meta.name); return ` @@ -765,7 +770,7 @@ class ToolPanel { } html(meta) { - const nonce = String(Date.now()); + const nonce = createCSPNonce(); const defaults = JSON.stringify(DEFAULTS[this.tool] || {}); const initialFileState = JSON.stringify(this.fileState || null); const name = escapeHtml(meta.name); diff --git a/vscode/nexus/extension.js b/vscode/nexus/extension.js index 6c8e5f5..c1fa6b9 100644 --- a/vscode/nexus/extension.js +++ b/vscode/nexus/extension.js @@ -2,10 +2,15 @@ const vscode = require('vscode'); const cp = require('child_process'); +const crypto = require('crypto'); const fs = require('fs'); const http = require('http'); const path = require('path'); +function createCSPNonce() { + return crypto.randomBytes(16).toString('base64'); +} + class Bridge { constructor(context) { this.context = context; @@ -211,7 +216,7 @@ class ConnectionsProvider { } html(webview) { - const nonce = String(Date.now()); + const nonce = createCSPNonce(); return ` @@ -546,7 +551,7 @@ class TerminalPanel { const fitJs = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'xterm-addon-fit.min.js')); const unicodeJs = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'xterm-addon-unicode11.min.js')); const xtermCss = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'xterm.css')); - const nonce = String(Date.now()); + const nonce = createCSPNonce(); const terminalFontFamily = JSON.stringify(this.profile.terminalFontFamily || ''); const terminalFontSize = Number(this.profile.terminalFontSize || 13); const terminalScrollback = Number(this.profile.terminalScrollback || 10000); @@ -887,7 +892,7 @@ class RawPanel { } html() { - const nonce = String(Date.now()); + const nonce = createCSPNonce(); return ` @@ -1053,7 +1058,7 @@ class GitHubPanel { } html() { - const nonce = String(Date.now()); + const nonce = createCSPNonce(); return ` @@ -1154,7 +1159,7 @@ class IRCPanel { } html() { - const nonce = String(Date.now()); + const nonce = createCSPNonce(); return ` @@ -1252,7 +1257,7 @@ class VNCPanel { const webview = this.panel.webview; const mediaRoot = vscode.Uri.file(path.join(this.context.extensionPath, 'media')); const noVncJs = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'novnc.min.js')); - const nonce = String(Date.now()); + const nonce = createCSPNonce(); const vncConfig = JSON.stringify((this.profile && this.profile.vnc) || {}); return ` @@ -1367,7 +1372,7 @@ class ProfileEditorPanel { } html() { - const nonce = String(Date.now()); + const nonce = createCSPNonce(); const profileJson = JSON.stringify(this.profile); return ` @@ -1610,7 +1615,7 @@ class SettingsPanel { } html() { - const nonce = String(Date.now()); + const nonce = createCSPNonce(); return ` @@ -1824,7 +1829,7 @@ class SFTPPanel { } html() { - const nonce = String(Date.now()); + const nonce = createCSPNonce(); return `