diff --git a/docs/05-advanced-topics.md b/docs/05-advanced-topics.md index 2607209..fabb277 100644 --- a/docs/05-advanced-topics.md +++ b/docs/05-advanced-topics.md @@ -188,6 +188,35 @@ proxy: # Other domains pass through ``` +### Browser-Based Clients (CORS) + +Upstream LLM APIs (`api.openai.com`, `api.anthropic.com`, …) do not return browser-friendly CORS headers — they are designed to be called from server code, not from a page running in a browser. When a web page issues `fetch("https://api.openai.com/v1/chat/completions", …)`, the browser sends a preflight `OPTIONS` request first, gets no `Access-Control-Allow-Origin` in the response, and aborts with `Failed to fetch`. + +When the proxy intercepts these requests (transparent proxy mode + PAC routing), it transparently fixes this: + +- **Preflight short-circuit.** `OPTIONS` requests to intercepted hosts are answered directly by the proxy with `204 No Content` plus CORS headers — they are not forwarded upstream. This means provider APIs don't see preflight traffic, and the browser gets the headers it needs. +- **Response header injection.** On normal (non-OPTIONS) intercepted responses, the proxy adds CORS headers after copying the upstream response headers, overriding any incompatible CORS values the upstream may have set. + +**Headers added:** + +| Header | Value | +| --- | --- | +| `Access-Control-Allow-Origin` | Echoes the request's `Origin` header (or `*` if absent) | +| `Access-Control-Allow-Credentials` | `true` (only when `Origin` is present) | +| `Access-Control-Allow-Headers` | Echoes `Access-Control-Request-Headers` from the preflight, or a default set covering `Authorization`, `Content-Type`, `OpenAI-Beta`, `anthropic-version`, etc. | +| `Access-Control-Allow-Methods` | `GET, POST, PUT, DELETE, OPTIONS, PATCH` | +| `Access-Control-Expose-Headers` | `*` | +| `Access-Control-Max-Age` | `3600` | + +The header policy lives in `src/backend/proxy/cors.go` and is applied from both intercept paths in `src/backend/proxy/transparent.go` (plain HTTP and MITM-decrypted TLS). + +**Prerequisites for browser callers:** + +1. The browser must use the proxy — either via the PAC at `http://localhost:9090/proxy.pac` set as Automatic Proxy Configuration, or via explicit proxy settings pointing to `127.0.0.1:8081`. +2. The browser must trust the Kiji CA. Chrome/Safari use the system keychain (see [Installing CA Certificate](#installing-ca-certificate)); Firefox keeps its own trust store. + +**Verifying it works:** the bundled demo at `src/scripts/app_demo/` exercises this path end-to-end — an `nginx`-served page at `http://localhost:8888` issues a `fetch` to `https://api.openai.com/v1/chat/completions`, the proxy intercepts, masks PII, forwards to OpenAI, restores PII, and the browser gets a usable response. + ### Security Considerations ⚠️ **Important:** @@ -243,6 +272,27 @@ sudo rm /etc/pki/ca-trust/source/anchors/kiji-proxy-ca.crt sudo update-ca-trust ``` +#### Migrating from "Yaak Proxy CA" + +Earlier builds of this project shipped the CA with the Common Name `Yaak Proxy CA` (a leftover from before the Kiji rebrand). If you installed a CA from one of those builds, the rename does **not** delete the old entry from your trust store — you'll have a stale `Yaak Proxy CA` cert alongside the new `Kiji Privacy Proxy CA`. The stale entry is harmless but clutters your keychain and may produce confusing "Number of trust settings" mismatches. + +To clean up after upgrading: + +```bash +# macOS — remove legacy "Yaak Proxy CA" from System keychain +sudo security delete-certificate -c "Yaak Proxy CA" /Library/Keychains/System.keychain + +# macOS — also check the user keychain +security delete-certificate -c "Yaak Proxy CA" ~/Library/Keychains/login.keychain 2>/dev/null || true + +# macOS — also delete the on-disk CA + key so the backend regenerates them +# under the new name on next startup +rm -f ~/"Library/Application Support/Kiji Privacy Proxy/certs/ca.crt" \ + ~/"Library/Application Support/Kiji Privacy Proxy/certs/ca.key" +``` + +After regeneration, install the new CA following [Installing CA Certificate](#installing-ca-certificate) and restart your browser. + ## Model Signing ### Overview diff --git a/src/backend/proxy/certmanager.go b/src/backend/proxy/certmanager.go index 4492f35..a3a22f6 100644 --- a/src/backend/proxy/certmanager.go +++ b/src/backend/proxy/certmanager.go @@ -96,13 +96,13 @@ func (cm *CertManager) generateCA() error { template := x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{ - Organization: []string{"Yaak Proxy CA"}, + Organization: []string{"Kiji Privacy Proxy CA"}, Country: []string{"US"}, Province: []string{""}, Locality: []string{""}, StreetAddress: []string{""}, PostalCode: []string{""}, - CommonName: "Yaak Proxy CA", + CommonName: "Kiji Privacy Proxy CA", }, NotBefore: time.Now(), NotAfter: time.Now().AddDate(10, 0, 0), // 10 years @@ -221,7 +221,7 @@ func (cm *CertManager) generateCert(hostname string) (*tls.Certificate, error) { template := x509.Certificate{ SerialNumber: serialNumber, Subject: pkix.Name{ - Organization: []string{"Yaak Proxy"}, + Organization: []string{"Kiji Privacy Proxy"}, Country: []string{"US"}, CommonName: host, SerialNumber: serialNumber.String(), diff --git a/src/backend/proxy/cors.go b/src/backend/proxy/cors.go new file mode 100644 index 0000000..6105e1c --- /dev/null +++ b/src/backend/proxy/cors.go @@ -0,0 +1,82 @@ +package proxy + +import ( + "bufio" + "io" + "log" + "net" + "net/http" +) + +// setCORSHeaders writes CORS response headers permissive enough for browser +// callers (e.g. the local demo at http://localhost:8888) to consume the +// intercepted LLM-provider response. Real upstream APIs (api.openai.com, +// api.anthropic.com, ...) do not return browser-friendly CORS headers, so the +// proxy must supply them itself. +func setCORSHeaders(h http.Header, r *http.Request) { + origin := r.Header.Get("Origin") + if origin == "" { + h.Set("Access-Control-Allow-Origin", "*") + } else { + // Echo the caller's origin so the response works with credentialed + // requests; wildcard isn't allowed when credentials are involved. + h.Set("Access-Control-Allow-Origin", origin) + h.Set("Access-Control-Allow-Credentials", "true") + h.Add("Vary", "Origin") + } + + if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" { + h.Set("Access-Control-Allow-Headers", reqHeaders) + } else { + h.Set("Access-Control-Allow-Headers", + "Content-Type, Authorization, X-Api-Key, OpenAI-Beta, "+ + "anthropic-version, anthropic-dangerous-direct-browser-access") + } + h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") + h.Set("Access-Control-Expose-Headers", "*") + h.Set("Access-Control-Max-Age", "3600") +} + +// writeCORSPreflight responds to a CORS preflight OPTIONS request over a +// standard ResponseWriter without forwarding it upstream. +func writeCORSPreflight(w http.ResponseWriter, r *http.Request) { + setCORSHeaders(w.Header(), r) + w.Header().Set("Content-Length", "0") + w.WriteHeader(http.StatusNoContent) +} + +// writeCORSPreflightOverTLS responds to a CORS preflight OPTIONS request over +// a raw (hijacked, MITM-decrypted) connection. +func writeCORSPreflightOverTLS(conn net.Conn, r *http.Request) { + resp := &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: make(http.Header), + Body: http.NoBody, + ContentLength: 0, + } + setCORSHeaders(resp.Header, r) + resp.Header.Set("Content-Length", "0") + + bw := bufio.NewWriter(conn) + if err := resp.Write(bw); err != nil { + log.Printf("[TransparentProxy] ❌ Failed to write OPTIONS preflight: %v", err) + return + } + if err := bw.Flush(); err != nil { + log.Printf("[TransparentProxy] ❌ Failed to flush OPTIONS preflight: %v", err) + } +} + +// drainAndClose discards any remaining request body and closes it. Used for +// OPTIONS short-circuits where we don't want to forward the body upstream. +func drainAndClose(body io.ReadCloser) { + if body == nil { + return + } + _, _ = io.Copy(io.Discard, body) + _ = body.Close() +} diff --git a/src/backend/proxy/transparent.go b/src/backend/proxy/transparent.go index 02e3aa4..7b1671b 100644 --- a/src/backend/proxy/transparent.go +++ b/src/backend/proxy/transparent.go @@ -135,6 +135,15 @@ func (tp *TransparentProxy) handleHTTPRequest(w http.ResponseWriter, r *http.Req func (tp *TransparentProxy) interceptHTTP(w http.ResponseWriter, r *http.Request, targetHost string, provider *providers.Provider) { log.Printf("[TransparentProxy] Intercepting HTTP request to %s", targetHost) + // Short-circuit CORS preflight: browsers fire OPTIONS before cross-origin + // POSTs with Authorization/JSON headers. Upstream LLM APIs don't return + // browser-friendly CORS, so the proxy answers preflights itself. + if r.Method == http.MethodOptions { + drainAndClose(r.Body) + writeCORSPreflight(w, r) + return + } + // Read request body body, err := io.ReadAll(r.Body) if err != nil { @@ -206,6 +215,10 @@ func (tp *TransparentProxy) interceptHTTP(w http.ResponseWriter, r *http.Request } } + // Inject CORS headers so browsers can read the response. Set after copying + // to override any (incompatible) CORS headers the upstream may have sent. + setCORSHeaders(w.Header(), r) + // Update Content-Length w.Header().Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody))) @@ -384,6 +397,15 @@ func (tp *TransparentProxy) interceptCONNECT(w http.ResponseWriter, _ *http.Requ // interceptHTTPOverTLS handles HTTP requests over a TLS connection // This method delegates to the shared Handler for PII processing to ensure consistency func (tp *TransparentProxy) interceptHTTPOverTLS(conn net.Conn, r *http.Request, targetHost string, provider *providers.Provider) { + // Short-circuit CORS preflight before any PII processing or forwarding — + // upstream providers don't return browser-friendly CORS. + if r.Method == http.MethodOptions { + log.Printf("[TransparentProxy] Responding to OPTIONS preflight for %s%s", targetHost, r.URL.Path) + drainAndClose(r.Body) + writeCORSPreflightOverTLS(conn, r) + return + } + // Read request body body, err := io.ReadAll(r.Body) if err != nil { @@ -456,6 +478,11 @@ func (tp *TransparentProxy) interceptHTTPOverTLS(conn net.Conn, r *http.Request, ContentLength: int64(len(modifiedBody)), } + // Inject CORS headers so browsers can read the response. Set after the + // upstream header copy to override any incompatible CORS values upstream + // may have sent. + setCORSHeaders(newResp.Header, r) + // Update Content-Length header newResp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody))) diff --git a/src/frontend/src/components/modals/CACertSetupModal.tsx b/src/frontend/src/components/modals/CACertSetupModal.tsx index 90111f2..e493284 100644 --- a/src/frontend/src/components/modals/CACertSetupModal.tsx +++ b/src/frontend/src/components/modals/CACertSetupModal.tsx @@ -6,6 +6,8 @@ import { Globe, ExternalLink, FolderOpen, + Copy, + Check, } from "lucide-react"; import { isElectron } from "../../utils/providerHelpers"; @@ -21,6 +23,23 @@ export default function CACertSetupModal({ const [dontShowAgain, setDontShowAgain] = useState(false); const [currentTab, setCurrentTab] = useState<"system" | "browsers">("system"); const [revealError, setRevealError] = useState(null); + const [commandCopied, setCommandCopied] = useState(false); + + const certInstallCommand = `sudo security add-trusted-cert \\ + -d \\ + -r trustRoot \\ + -k /Library/Keychains/System.keychain \\ + ~/"Library/Application Support/Kiji Privacy Proxy/certs/ca.crt"`; + + const handleCopyCommand = async () => { + try { + await navigator.clipboard.writeText(certInstallCommand); + setCommandCopied(true); + setTimeout(() => setCommandCopied(false), 2000); + } catch (error) { + console.error("Failed to copy cert install command:", error); + } + }; if (!isOpen) return null; @@ -132,16 +151,32 @@ export default function CACertSetupModal({

Option 1: Command Line (Recommended)

-
- - sudo security add-trusted-cert \ -
- {" "}-d \
- {" "}-r trustRoot \
- {" "}-k /Library/Keychains/System.keychain \
- {" "}~/Library/Application Support/Kiji Privacy - Proxy/certs/ca.crt -
+
+ +
+                    {certInstallCommand}
+                  

This command requires administrator privileges and will