fix(agent): base64-envelope /agent/* bodies to bypass Render CF WAF
This commit is contained in:
@@ -2,12 +2,17 @@ import type { Context, Next } from 'hono';
|
|||||||
import { AGENT_CONTENT_TYPE } from '@cio/utils/constants';
|
import { AGENT_CONTENT_TYPE } from '@cio/utils/constants';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render fronts services with Cloudflare's OWASP Managed Rules, which scan
|
* The dashboard wraps `/agent/*` write bodies in a base64 envelope —
|
||||||
* `application/json` bodies and 403 anything that looks like SQL injection.
|
* `{"b64":"<base64 of inner JSON>"}` — and tags them with
|
||||||
* AI chat content trips this constantly (`SELECT … WHERE …`, `OR 1=1` etc.).
|
* `application/x-cio-agent`. Render fronts our service with its own Cloudflare,
|
||||||
* The dashboard sends `/agent/*` writes as `application/x-cio-agent`; this
|
* whose OWASP managed rules body-inspect every JSON request and 403 anything
|
||||||
* middleware swaps the header back to `application/json` so Hono's zValidator
|
* that looks like SQL injection / XSS / command injection / markdown
|
||||||
* (which gates parsing on a `/application\/.+?\+?json/` regex) parses the body.
|
* 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) => {
|
export const agentContentTypeRewrite = async (c: Context, next: Next) => {
|
||||||
const ct = c.req.header('content-type')?.toLowerCase() ?? '';
|
const ct = c.req.header('content-type')?.toLowerCase() ?? '';
|
||||||
@@ -15,9 +20,27 @@ export const agentContentTypeRewrite = async (c: Context, next: Next) => {
|
|||||||
return 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":"<base64 of inner JSON>"} → decode
|
||||||
|
// - old dashboard: <inner JSON directly> → 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);
|
const headers = new Headers(c.req.raw.headers);
|
||||||
headers.set('content-type', 'application/json');
|
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();
|
return next();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<AbortSignal | null | undefined>) {
|
function mergeAbortSignals(...signals: Array<AbortSignal | null | undefined>) {
|
||||||
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
|
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
|
||||||
|
|
||||||
@@ -127,6 +147,12 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (requestBody && !isFormData && isAgentRequest(input)) {
|
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);
|
headers.set('Content-Type', AGENT_CONTENT_TYPE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,19 +75,11 @@ export default {
|
|||||||
upstreamHeaders.set('x-forwarded-for', clientIp);
|
upstreamHeaders.set('x-forwarded-for', clientIp);
|
||||||
}
|
}
|
||||||
|
|
||||||
const upstreamResponse = await fetch(upstreamUrl.toString(), {
|
return fetch(upstreamUrl.toString(), {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
headers: upstreamHeaders,
|
headers: upstreamHeaders,
|
||||||
body: request.body,
|
body: request.body,
|
||||||
redirect: 'manual'
|
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<Env>;
|
} satisfies ExportedHandler<Env>;
|
||||||
|
|||||||
Reference in New Issue
Block a user