# Transport-layer WAF bypass for CLIProxyAPI (Render 403 on SQL content)

## Context

Claude Code sends the full conversation on every turn, including raw **tool
results** — the bytes returned by `Read` / `Grep` / `Bash`. Traffic is routed to a
self-hosted **CLIProxyAPI** at `https://render-cliproxyapi-pldo.onrender.com`,
which sits behind Render's Cloudflare-backed **WAF**.

When Claude Code explores a repo containing `.sql` files, raw SQL grammar
(`UNION SELECT`, `INSERT INTO`, `CREATE TABLE …`, `' OR 1=1`) lands in the request
body. Cloudflare's managed **SQL-injection ruleset** matches it and returns a
Render-branded `403 Blocked` page (confirmed from `.claude/debug/*.txt`). Claude
Code mis-reports it as `Please run /login`. The blocked content stays in history,
so every retry and follow-up in that session also fails.

**Why not just encrypt in the prompt:** Claude does not build the HTTP body —
Claude Code does, and no hook rewrites the outgoing body. The raw SQL is injected
by the CLI as a tool result. The only place to neutralize it without touching every
tool call is **on the wire**, between Claude Code and Render.

**Chosen fix:** a local relay encodes each request body into WAF-opaque bytes
before it crosses Cloudflare; a decode step inside CLIProxyAPI restores the
original body after the edge. Cloudflare then sees only high-entropy base64, never
SQL grammar.

Goal here is **WAF evasion, not secrecy** — gzip+base64 is enough. An optional
AES variant is noted at the end if you also want the body unreadable to Render.

## Architecture

```
Claude Code  --http-->  local relay (127.0.0.1)  --https via corp proxy-->  Cloudflare/Render  -->  CLIProxyAPI
             plaintext            gzip+base64 envelope                    (WAF sees base64 only)   decode middleware -> normal body
```

Key facts that shape the design:
- **Requests only.** Cloudflare's WAF inspects inbound requests. Responses
  (origin → client) are not filtered, so responses are streamed back untouched —
  SSE keeps working with zero decoding on the return path.
- **Localhost bypasses the corporate proxy.** `NO_PROXY` already lists
  `localhost,127.0.0.1`, so Claude Code reaches the relay directly. The relay then
  forwards to Render **through** `HTTPS_PROXY` (the corp proxy), exactly as today.
- **Always-encode POST bodies.** No "is this SQL?" heuristic — every POST/PUT/PATCH
  body is encoded, so there is nothing for the model to remember and no gap.
- **GET passes through** (model discovery `/v1/models`, etc. — no body, no risk).

## Component 1 — local encoding relay (you fully control this)

Single-file Node relay. Node is already on PATH. Save as `waf-relay.js`.

```js
// waf-relay.js — encodes request bodies so Render's WAF can't see SQL grammar.
// Run:  node waf-relay.js     (listens on http://127.0.0.1:8788)
const http  = require('http');
const https = require('https');
const zlib  = require('zlib');
const { URL } = require('url');
const { HttpsProxyAgent } = require('https-proxy-agent'); // npm i https-proxy-agent

const LISTEN_PORT = 8788;
const UPSTREAM    = 'https://render-cliproxyapi-pldo.onrender.com';
const CORP_PROXY  = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || '';
const agent       = CORP_PROXY ? new HttpsProxyAgent(CORP_PROXY) : undefined;

http.createServer((req, res) => {
  const chunks = [];
  req.on('data', c => chunks.push(c));
  req.on('end', () => {
    const raw = Buffer.concat(chunks);
    const up  = new URL(req.url, UPSTREAM);
    const headers = { ...req.headers };
    delete headers['host'];
    delete headers['content-length'];
    delete headers['accept-encoding'];          // keep the SSE response plain

    let body = raw;
    if (raw.length && /^(POST|PUT|PATCH)$/.test(req.method)) {
      const packed = zlib.gzipSync(raw).toString('base64');
      body = Buffer.from(JSON.stringify({ enc: 'gzip-b64', data: packed }));
      headers['content-type']   = 'application/json';
      headers['x-body-encoding'] = 'gzip-b64';   // signal for the decoder
    }
    headers['content-length'] = Buffer.byteLength(body);

    const upReq = https.request({
      method: req.method, hostname: up.hostname, port: 443,
      path: up.pathname + up.search, headers, agent,
    }, upRes => {
      res.writeHead(upRes.statusCode, upRes.headers);
      upRes.pipe(res);                            // stream response straight back
    });
    upReq.on('error', e => { res.writeHead(502); res.end('relay error: ' + e.message); });
    upReq.end(body);
  });
}).listen(LISTEN_PORT, '127.0.0.1', () =>
  console.log(`WAF relay :${LISTEN_PORT} -> ${UPSTREAM} via ${CORP_PROXY || 'direct'}`));
```

Setup:
```
npm i https-proxy-agent      # in the folder holding waf-relay.js
node waf-relay.js            # keep running in a terminal; or use pm2/nssm to daemonize
```

## Component 2 — decode step inside CLIProxyAPI (after the edge)

Add middleware that runs **before** auth/routing/body-parsing. It detects the
`X-Body-Encoding: gzip-b64` header, unwraps the envelope, and replaces the request
body with the original JSON so the rest of CLIProxyAPI behaves exactly as before.

The algorithm, in any language:
1. If header `X-Body-Encoding == gzip-b64`: read body, `JSON.parse` → `{enc,data}`.
2. `base64-decode(data)` → gzip bytes → `gunzip` → original body bytes.
3. Replace the request body with those bytes; set `Content-Type: application/json`;
   drop the `X-Body-Encoding` header; fix `Content-Length`.
4. Otherwise pass through untouched.

CLIProxyAPI is Go/Gin — register this as the **first** middleware (`r.Use(...)`
before routes and auth):

```go
func WafDecode() gin.HandlerFunc {
    return func(c *gin.Context) {
        if c.GetHeader("X-Body-Encoding") == "gzip-b64" {
            raw, _ := io.ReadAll(c.Request.Body)
            var env struct {
                Enc  string `json:"enc"`
                Data string `json:"data"`
            }
            if json.Unmarshal(raw, &env) == nil && env.Enc == "gzip-b64" {
                gzb, _ := base64.StdEncoding.DecodeString(env.Data)
                gr, err := gzip.NewReader(bytes.NewReader(gzb))
                if err == nil {
                    orig, _ := io.ReadAll(gr)
                    c.Request.Body = io.NopCloser(bytes.NewReader(orig))
                    c.Request.ContentLength = int64(len(orig))
                    c.Request.Header.Del("X-Body-Encoding")
                    c.Request.Header.Set("Content-Type", "application/json")
                }
            }
        }
        c.Next()
    }
}
// in router setup, BEFORE routes/auth:  r.Use(WafDecode())
```

If your CLIProxyAPI build is Node/Express instead, it's the same four steps:

```js
app.use((req, res, next) => {
  if (req.headers['x-body-encoding'] !== 'gzip-b64') return next();
  const chunks = [];
  req.on('data', c => chunks.push(c));
  req.on('end', () => {
    try {
      const { data } = JSON.parse(Buffer.concat(chunks));
      req.body = null;                                   // let downstream re-parse
      const orig = require('zlib').gunzipSync(Buffer.from(data, 'base64'));
      req.headers['content-type']   = 'application/json';
      req.headers['content-length'] = Buffer.byteLength(orig);
      delete req.headers['x-body-encoding'];
      req.unshift(orig);                                 // re-emit original body
    } catch (_) {}
    next();
  });
});
```

**No-source-change alternative:** if you would rather not patch CLIProxyAPI,
deploy a tiny decoder as the Render web service (public entrypoint) that performs
steps 1–4 and reverse-proxies to CLIProxyAPI on an internal port. More moving
parts; the middleware is simpler.

## Config change (Claude Code)

In `C:\Users\wikawkli\.claude\settings.json`, point the base URL at the relay.
Everything else (auth token, corp proxy, `NO_PROXY`) stays the same.

```jsonc
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8788"   // was https://render-cliproxyapi-pldo.onrender.com
```

`ANTHROPIC_AUTH_TOKEN`, `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` unchanged — the
relay reads `HTTPS_PROXY` from the environment to reach Render, and `NO_PROXY`
already exempts localhost so Claude Code reaches the relay directly.

## Verification

1. Start the relay; its log shows `... via http://…:8080` (the corp proxy picked up).
2. Deploy CLIProxyAPI with the decode middleware.
3. Sanity: a normal Claude Code turn completes (proves encode↔decode round-trips
   and SSE streaming still works).
4. The real test: open a repo with a `.sql` file, `Read` it, and confirm the turn
   completes with **no 403 Blocked**.
5. On the relay/Render side, capture one outbound body and confirm it is
   `{"enc":"gzip-b64","data":"H4sI…"}` with no SQL tokens visible.

## Rollback

Set `ANTHROPIC_BASE_URL` back to the Render URL and stop the relay. The decode
middleware is inert for un-encoded requests (no `X-Body-Encoding` header), so it
can stay deployed safely.

## Optional upgrade — real encryption (unreadable to Render, not just WAF-opaque)

Swap gzip for AES-256-GCM with a pre-shared key in both the relay and CLIProxyAPI
(e.g. `WAF_KEY` env var, 32 bytes). Envelope becomes
`{"enc":"aes-gcm","iv":"…","data":"…","tag":"…"}`; the decoder reverses it. Same
placement and flow; only the transform changes. Use this only if you need the body
confidential from Render itself — for defeating the SQLi rule, gzip+base64 already
suffices, and the key would have to ship to both ends anyway.
