From 4e4119d597f6bafad401fb45b68dfb8b75f30e2a Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Thu, 21 May 2026 19:43:23 +0100 Subject: [PATCH] fix(agent): base64-envelope /agent/* bodies to bypass Render CF WAF --- .../api/src/middlewares/agent-content-type.ts | 37 +++++++++++++++---- .../src/lib/utils/services/api/index.ts | 26 +++++++++++++ apps/tenant-router/src/index.ts | 10 +---- 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/apps/api/src/middlewares/agent-content-type.ts b/apps/api/src/middlewares/agent-content-type.ts index c9328a73..5db76ef4 100644 --- a/apps/api/src/middlewares/agent-content-type.ts +++ b/apps/api/src/middlewares/agent-content-type.ts @@ -2,12 +2,17 @@ import type { Context, Next } from 'hono'; import { AGENT_CONTENT_TYPE } from '@cio/utils/constants'; /** - * Render fronts services with Cloudflare's OWASP Managed Rules, which scan - * `application/json` bodies and 403 anything that looks like SQL injection. - * AI chat content trips this constantly (`SELECT … WHERE …`, `OR 1=1` etc.). - * The dashboard sends `/agent/*` writes as `application/x-cio-agent`; this - * middleware swaps the header back to `application/json` so Hono's zValidator - * (which gates parsing on a `/application\/.+?\+?json/` regex) parses the body. + * The dashboard wraps `/agent/*` write bodies in a base64 envelope — + * `{"b64":""}` — and tags them with + * `application/x-cio-agent`. Render fronts our service with its own Cloudflare, + * whose OWASP managed rules body-inspect every JSON request and 403 anything + * that looks like SQL injection / XSS / command injection / markdown + * jailbreak — patterns that AI-generated lesson content, fetched docs, and + * the conversation history naturally produce. CF cannot pattern-match + * base64, so the envelope passes inspection. This middleware unwraps the + * envelope and restores a plain `application/json` body before zValidator + * (which gates on a `/application\/.+?\+?json/` regex) parses it, so route + * handlers downstream see the request unchanged. */ export const agentContentTypeRewrite = async (c: Context, next: Next) => { const ct = c.req.header('content-type')?.toLowerCase() ?? ''; @@ -15,9 +20,27 @@ export const agentContentTypeRewrite = async (c: Context, next: Next) => { return next(); } + const envelope = await c.req.raw.text(); + let innerJson: string; + try { + const parsed = JSON.parse(envelope) as { b64?: string }; + // Accept either shape during the deploy window: + // - new dashboard: {"b64":""} → decode + // - old dashboard: → pass through + // The old path still risks a WAF 403 but at least keeps requests flowing + // until the dashboard rollout catches up. + innerJson = typeof parsed.b64 === 'string' ? Buffer.from(parsed.b64, 'base64').toString('utf-8') : envelope; + } catch { + return c.json({ success: false, error: 'INVALID_AGENT_ENVELOPE', code: 'INVALID_AGENT_ENVELOPE' }, 400); + } + const headers = new Headers(c.req.raw.headers); headers.set('content-type', 'application/json'); - c.req.raw = new Request(c.req.raw, { headers }); + c.req.raw = new Request(c.req.raw.url, { + method: c.req.raw.method, + headers, + body: innerJson + }); return next(); }; diff --git a/apps/dashboard/src/lib/utils/services/api/index.ts b/apps/dashboard/src/lib/utils/services/api/index.ts index e3814bf2..79cbccf6 100644 --- a/apps/dashboard/src/lib/utils/services/api/index.ts +++ b/apps/dashboard/src/lib/utils/services/api/index.ts @@ -42,6 +42,26 @@ function isAgentRequest(input: RequestInfo | URL): boolean { } } +/** + * Base64-encode a UTF-8 string for the `/agent/*` body envelope. Works on + * both the SvelteKit server (Node `Buffer`) and the browser (TextEncoder + + * `btoa`). `btoa` alone fails on non-Latin-1 characters; encoding through + * bytes first preserves arbitrary Unicode. + */ +function toBase64Utf8(input: string): string { + if (typeof Buffer !== 'undefined') { + return Buffer.from(input, 'utf-8').toString('base64'); + } + + const bytes = new TextEncoder().encode(input); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]!); + } + + return btoa(binary); +} + function mergeAbortSignals(...signals: Array) { const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal)); @@ -127,6 +147,12 @@ class ApiClient { } if (requestBody && !isFormData && isAgentRequest(input)) { + // Wrap the inner JSON in a base64 envelope so Render's Cloudflare WAF + // can't body-inspect the content (AI-generated HTML, code blocks, curl + // examples, etc. trip OWASP rules). The API's `agentContentTypeRewrite` + // middleware decodes the envelope before any route handler sees it. + const inner = typeof requestBody === 'string' ? requestBody : JSON.stringify(requestBody); + requestBody = JSON.stringify({ b64: toBase64Utf8(inner) }); headers.set('Content-Type', AGENT_CONTENT_TYPE); } diff --git a/apps/tenant-router/src/index.ts b/apps/tenant-router/src/index.ts index d85f9c38..0692255b 100644 --- a/apps/tenant-router/src/index.ts +++ b/apps/tenant-router/src/index.ts @@ -75,19 +75,11 @@ export default { upstreamHeaders.set('x-forwarded-for', clientIp); } - const upstreamResponse = await fetch(upstreamUrl.toString(), { + return fetch(upstreamUrl.toString(), { method: request.method, headers: upstreamHeaders, body: request.body, redirect: 'manual' }); - - if (isProxiedApiCall && upstreamResponse.status >= 400) { - console.log( - `[proxy] ${request.method} ${url.pathname} → ${upstreamHost}${upstreamPath} status=${upstreamResponse.status} ct=${request.headers.get('content-type') ?? ''} upstream-cf-ray=${upstreamResponse.headers.get('cf-ray') ?? 'none'} upstream-server=${upstreamResponse.headers.get('server') ?? 'none'}` - ); - } - - return upstreamResponse; } } satisfies ExportedHandler;