Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/05-advanced-topics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/backend/proxy/certmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
82 changes: 82 additions & 0 deletions src/backend/proxy/cors.go
Original file line number Diff line number Diff line change
@@ -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()
}
27 changes: 27 additions & 0 deletions src/backend/proxy/transparent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)))

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)))

Expand Down
55 changes: 45 additions & 10 deletions src/frontend/src/components/modals/CACertSetupModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
Globe,
ExternalLink,
FolderOpen,
Copy,
Check,
} from "lucide-react";
import { isElectron } from "../../utils/providerHelpers";

Expand All @@ -21,6 +23,23 @@ export default function CACertSetupModal({
const [dontShowAgain, setDontShowAgain] = useState(false);
const [currentTab, setCurrentTab] = useState<"system" | "browsers">("system");
const [revealError, setRevealError] = useState<string | null>(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;

Expand Down Expand Up @@ -132,16 +151,32 @@ export default function CACertSetupModal({
<h3 className="text-sm font-semibold text-slate-700 mb-2">
Option 1: Command Line (Recommended)
</h3>
<div className="bg-slate-900 rounded-lg p-4 text-sm font-mono text-slate-100 overflow-x-auto">
<code>
sudo security add-trusted-cert \
<br />
{" "}-d \<br />
{" "}-r trustRoot \<br />
{" "}-k /Library/Keychains/System.keychain \<br />
{" "}~/Library/Application Support/Kiji Privacy
Proxy/certs/ca.crt
</code>
<div className="relative bg-slate-900 rounded-lg p-4 text-sm font-mono text-slate-100 overflow-x-auto">
<button
onClick={handleCopyCommand}
className="absolute top-2 right-2 inline-flex items-center gap-1 px-2 py-1 text-xs text-slate-300 hover:text-white hover:bg-slate-700 rounded transition-colors"
aria-label={
commandCopied
? "Command copied to clipboard"
: "Copy command to clipboard"
}
title={commandCopied ? "Copied!" : "Copy to clipboard"}
>
{commandCopied ? (
<>
<Check className="w-3.5 h-3.5 text-green-400" />
<span>Copied</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
<span>Copy</span>
</>
)}
</button>
<pre className="whitespace-pre pr-20">
<code>{certInstallCommand}</code>
</pre>
</div>
<p className="text-xs text-slate-600 mt-2">
This command requires administrator privileges and will
Expand Down
Loading