fix: encode body of translation updating, reduce p margin in prose

This commit is contained in:
Rotimi Best
2026-06-25 22:12:38 +01:00
parent 46acad8052
commit 50a4f24b7c
10 changed files with 132 additions and 67 deletions
@@ -1,46 +0,0 @@
import type { Context, Next } from 'hono';
import { AGENT_CONTENT_TYPE } from '@cio/utils/constants';
/**
* The dashboard wraps `/agent/*` write bodies in a base64 envelope —
* `{"b64":"<base64 of inner JSON>"}` — 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() ?? '';
if (!ct.startsWith(AGENT_CONTENT_TYPE)) {
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);
headers.set('content-type', 'application/json');
c.req.raw = new Request(c.req.raw.url, {
method: c.req.raw.method,
headers,
body: innerJson
});
return next();
};
+41
View File
@@ -0,0 +1,41 @@
import type { Context, Next } from 'hono';
import { CIO_ENVELOPE_CONTENT_TYPE } from '@cio/utils/constants';
/**
* Unwraps the base64 envelope used on write endpoints whose bodies contain
* HTML or AI-generated content. The dashboard wraps such bodies as
* `{"b64":"<base64 of inner JSON>"}` with `Content-Type: application/x-cio-agent`
* so Render's Cloudflare WAF (OWASP managed rules) cannot body-inspect the
* payload. This middleware decodes the envelope and restores a plain
* `application/json` body before zValidator runs.
*/
export const b64EnvelopeRewrite = async (c: Context, next: Next) => {
const ct = c.req.header('content-type')?.toLowerCase() ?? '';
if (!ct.startsWith(CIO_ENVELOPE_CONTENT_TYPE)) {
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_B64_ENVELOPE', code: 'INVALID_B64_ENVELOPE' }, 400);
}
const headers = new Headers(c.req.raw.headers);
headers.set('content-type', 'application/json');
c.req.raw = new Request(c.req.raw.url, {
method: c.req.raw.method,
headers,
body: innerJson
});
return next();
};
+2 -2
View File
@@ -4,7 +4,7 @@ import { authMiddleware } from '@api/middlewares/auth';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
import { agentContentTypeRewrite } from '@api/middlewares/agent-content-type';
import { b64EnvelopeRewrite } from '@api/middlewares/b64-envelope';
import { handleError, AppError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
import { streamText, stepCountIs, convertToModelMessages } from 'ai';
@@ -690,7 +690,7 @@ const agentCoreRouter = new Hono()
});
export const agentRouter = new Hono()
.use('*', agentContentTypeRewrite)
.use('*', b64EnvelopeRewrite)
.route('/', agentCoreRouter)
.route('/history', agentHistoryRouter)
.route('/runs', agentRunsRouter);
@@ -13,12 +13,14 @@ import {
import { Hono } from '@api/utils/hono';
import type { TLocale } from '@db/types';
import { b64EnvelopeRewrite } from '@api/middlewares/b64-envelope';
import { authMiddleware } from '@api/middlewares/auth';
import { courseMemberMiddleware } from '@api/middlewares/course-member';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const lessonLanguageRouter = new Hono()
.use('*', b64EnvelopeRewrite)
/**
* GET /course/:courseId/lesson/:lessonId/language
* Gets all language translations for a lesson
+1 -1
View File
@@ -118,7 +118,7 @@ html .dark {
}
.prose p {
@apply mb-4;
@apply mb-1;
}
.prose a {
@@ -9,7 +9,7 @@ import { currentOrg } from '$lib/utils/store/org';
import type { Cookies } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';
import { getCioCookieString } from '$lib/utils/functions/cookies';
import { AGENT_CONTENT_TYPE } from '@cio/utils/constants';
import { CIO_ENVELOPE_CONTENT_TYPE } from '@cio/utils/constants';
export const getRequestBaseUrl = () => {
if (typeof window === 'undefined') {
@@ -32,11 +32,11 @@ export const getRequestBaseUrl = () => {
return `${window.location.origin}/proxy`;
};
function isAgentRequest(input: RequestInfo | URL): boolean {
function requiresB64Envelope(input: RequestInfo | URL): boolean {
try {
const raw = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
const pathname = new URL(raw, 'http://placeholder').pathname;
return pathname.includes('/agent/');
return pathname.includes('/agent/') || /\/lesson\/[^/]+\/language/.test(pathname);
} catch {
return false;
}
@@ -146,14 +146,14 @@ class ApiClient {
headers.delete('Content-Type');
}
if (requestBody && !isFormData && isAgentRequest(input)) {
if (requestBody && !isFormData && requiresB64Envelope(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`
// examples, etc. trip OWASP rules). The API's `b64EnvelopeRewrite`
// 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', CIO_ENVELOPE_CONTENT_TYPE);
}
// Create abort controller for timeout