harden input validation and outbound URL handling (#1387)

* harden input validation and outbound URL handling

- Validate the classroom id on the write path with the same allowlist the
  read path already applies, and assert in the storage layer that a resolved
  classroom file stays inside CLASSROOMS_DIR.
- Apply validateUrlForSSRF at every call site regardless of NODE_ENV; the
  guard already owns the ALLOW_LOCAL_NETWORKS escape hatch, so the extra
  environment condition only disabled the check outside production builds.
  Add a repository-scanning test so a gated call site cannot reappear.
- Re-validate every redirect hop of an outbound provider request through a
  shared transport, mirroring the per-hop loop proxy-media already uses.
- Restrict stored slide HTML to the formatting vocabulary the renderer
  produces, sanitizing at the classroom persistence boundary on both write
  and read, with a separate policy for KaTeX snapshots.
- Refuse the development persistence authenticator under NODE_ENV=production
  unless an explicit opt-in is set, and document it in .env.example.

* drop credential headers on a cross-origin redirect hop

The manual redirect loop reused the caller's init on every hop, so provider
credentials were re-sent to the redirect target even on a different origin.
The platform fetch drops Authorization itself when it follows redirects, so
mirror that: strip authorization, api-key, x-api-key and x-goog-api-key
before a cross-origin hop, matching case-insensitively and preserving the
shape of the caller's headers. Same-origin hops are untouched.

A streaming request body cannot be replayed onto the next hop, so fail with
a clear message rather than sending an empty body.

---------

Co-authored-by: wyuc <dhq1024@proton.me>
This commit is contained in:
wyuc
2026-09-05 23:56:37 -07:00
committed by GitHub
co-authored by wyuc
parent 92d8b32a5a
commit 0bab621e09
30 changed files with 2715 additions and 30 deletions
+6
View File
@@ -502,6 +502,12 @@ DEFAULT_MODEL=
# DATABASE_URL=postgres://openmaic:password@postgres:5432/openmaic
# PERSISTENCE_DEV_TOKEN=
# The development authenticator above is refused when NODE_ENV=production
# unless this explicit opt-in is set. It provides no user isolation (the
# learner key is client-supplied), so enabling it in production is only safe
# on a trusted-network, single-user deployment.
# PERSISTENCE_ALLOW_INSECURE_DEV_AUTH=true
# Store asset bytes in S3 instead of PostgreSQL. Region, endpoint, and credentials
# are resolved through the standard AWS SDK environment / credential chain.
# ASSET_S3_BUCKET=
+58 -2
View File
@@ -1,5 +1,6 @@
import { type NextRequest } from 'next/server';
import { randomUUID } from 'crypto';
import { validateScene } from '@openmaic/dsl';
import { apiSuccess, apiError, API_ERROR_CODES } from '@/lib/server/api-response';
import {
buildRequestOrigin,
@@ -7,10 +8,16 @@ import {
persistClassroom,
readClassroom,
} from '@/lib/server/classroom-storage';
import { sanitizeSceneContent } from '@/lib/server/sanitize-scene-content';
import { createLogger } from '@/lib/logger';
const log = createLogger('Classroom API');
function describeSceneIssue(issue: { path: string; message: string }): string {
const at = issue.path && issue.path !== '' ? issue.path : '/';
return `${at}: ${issue.message}`;
}
export async function POST(request: NextRequest) {
let stageId: string | undefined;
let sceneCount: number | undefined;
@@ -28,10 +35,55 @@ export async function POST(request: NextRequest) {
);
}
if (typeof stage !== 'object' || Array.isArray(stage)) {
return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, 'Invalid classroom stage');
}
if (!Array.isArray(scenes)) {
return apiError(
API_ERROR_CODES.INVALID_REQUEST,
400,
'Invalid classroom scenes: must be an array',
);
}
// The scenes must already have the shape the slide DSL declares (id,
// stageId, title, order, type and a content payload bound to that type).
// Rejecting malformed scenes here keeps garbage out of storage instead of
// letting viewers choke on it later.
for (const [index, scene] of scenes.entries()) {
const result = validateScene(scene);
if (!result.valid) {
const first = result.errors[0];
return apiError(
API_ERROR_CODES.INVALID_REQUEST,
400,
`Invalid classroom scene at index ${index}`,
first ? describeSceneIssue(first) : undefined,
);
}
}
const id = stage.id || randomUUID();
// An id that fails the allowlist never reaches the filesystem: the storage
// layer joins the id into CLASSROOMS_DIR, so a traversal-style id must be
// rejected here with the same contract the read side already enforces.
if (!isValidClassroomId(id)) {
return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, 'Invalid classroom id');
}
const baseUrl = buildRequestOrigin(request);
const persisted = await persistClassroom({ id, stage: { ...stage, id }, scenes }, baseUrl);
// Sanitize every HTML-bearing string in the payload before it reaches
// storage: stored slide HTML is restricted to the formatting vocabulary
// the renderer produces (see sanitize-scene-content.ts).
const safeStage = sanitizeSceneContent(stage);
const safeScenes = sanitizeSceneContent(scenes);
const persisted = await persistClassroom(
{ id, stage: { ...safeStage, id }, scenes: safeScenes },
baseUrl,
);
return apiSuccess({ id: persisted.id, url: persisted.url }, 201);
} catch (error) {
@@ -69,7 +121,11 @@ export async function GET(request: NextRequest) {
return apiError(API_ERROR_CODES.INVALID_REQUEST, 404, 'Classroom not found');
}
return apiSuccess({ classroom });
// Classroom files written before this change were stored unsanitized and
// cannot be migrated on deployments we do not control. Run the same
// sanitizer over the payload on the way out so already-stored content is
// cleaned at the single serve choke point too.
return apiSuccess({ classroom: sanitizeSceneContent(classroom) });
} catch (error) {
log.error(
`Classroom retrieval failed [id=${request.nextUrl.searchParams.get('id') ?? 'unknown'}]:`,
+2 -2
View File
@@ -255,7 +255,7 @@ async function runExtraction(
const mediaClientBaseUrl = mediaManaged ? undefined : requestConfig.baseUrl || undefined;
// Same SSRF guard the document path applies: a client-supplied endpoint
// must not let the server connect to internal/metadata hosts.
if (mediaClientBaseUrl && process.env.NODE_ENV === 'production') {
if (mediaClientBaseUrl) {
const ssrfError = await validateUrlForSSRF(mediaClientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
@@ -383,7 +383,7 @@ async function runExtraction(
);
}
}
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
if (clientBaseUrl) {
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
+1 -1
View File
@@ -67,7 +67,7 @@ export async function POST(request: NextRequest) {
const clientBaseUrl = managed ? undefined : request.headers.get('x-base-url') || undefined;
const clientModel = request.headers.get('x-image-model')?.trim() || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
if (clientBaseUrl) {
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
+1 -1
View File
@@ -62,7 +62,7 @@ export async function POST(request: NextRequest) {
const clientBaseUrl = managed ? undefined : request.headers.get('x-base-url') || undefined;
const clientModel = request.headers.get('x-video-model')?.trim() || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
if (clientBaseUrl) {
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
+1 -1
View File
@@ -44,7 +44,7 @@ export async function POST(req: NextRequest) {
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
const managed = isServerConfiguredProvider('pdf', effectiveProviderId);
const clientBaseUrl = managed ? undefined : baseUrl || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
if (clientBaseUrl) {
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
+1 -1
View File
@@ -54,7 +54,7 @@ export async function POST(req: NextRequest) {
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
const managed = isServerConfiguredProvider('asr', effectiveProviderId);
const clientBaseUrl = managed ? undefined : baseUrl || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
if (clientBaseUrl) {
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
+1 -1
View File
@@ -54,7 +54,7 @@ export async function POST(request: NextRequest) {
const clientApiKey = managed ? undefined : request.headers.get('x-api-key') || undefined;
const clientBaseUrl = managed ? undefined : request.headers.get('x-base-url') || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
if (clientBaseUrl) {
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
+3 -3
View File
@@ -55,7 +55,7 @@ export async function POST(req: NextRequest) {
);
}
// Validate a client-supplied endpoint before we sign a request to it.
if (endpoint && process.env.NODE_ENV === 'production') {
if (endpoint) {
const ssrfError = await validateUrlForSSRF(
endpoint.startsWith('http') ? endpoint : `https://${endpoint}`,
);
@@ -80,7 +80,7 @@ export async function POST(req: NextRequest) {
// MinerU Cloud: verify by calling the cloud API with the token
if (providerId === 'mineru-cloud') {
const clientCloudBase = managed ? undefined : (baseUrl as string | undefined) || undefined;
if (clientCloudBase && process.env.NODE_ENV === 'production') {
if (clientCloudBase) {
const ssrfError = await validateUrlForSSRF(clientCloudBase);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
@@ -129,7 +129,7 @@ export async function POST(req: NextRequest) {
// Self-hosted providers: verify by connecting to the base URL
const clientBaseUrl = managed ? undefined : (baseUrl as string | undefined) || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
if (clientBaseUrl) {
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
+1 -1
View File
@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
const clientApiKey = managed ? undefined : request.headers.get('x-api-key') || undefined;
const clientBaseUrl = managed ? undefined : request.headers.get('x-base-url') || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
if (clientBaseUrl) {
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
+27 -9
View File
@@ -1897,20 +1897,21 @@ function openAIStreamErrorStatus(error: Record<string, unknown>): number {
async function fetchCustomOpenAIChat(
input: RequestInfo | URL,
init?: RequestInit,
fetchImpl: typeof fetch = (fetchInput, fetchInit) => globalThis.fetch(fetchInput, fetchInit),
): Promise<Response> {
const requestUrl = requestUrlString(input);
if (!requestUrl.includes('/chat/completions') || !init?.body || typeof init.body !== 'string') {
return globalThis.fetch(input, init);
return fetchImpl(input, init);
}
let requestBody: Record<string, unknown>;
try {
requestBody = JSON.parse(init.body) as Record<string, unknown>;
} catch {
return globalThis.fetch(input, init);
return fetchImpl(input, init);
}
if (requestBody.stream === true) return globalThis.fetch(input, init);
if (requestBody.stream === true) return fetchImpl(input, init);
const streamOptions =
requestBody.stream_options &&
@@ -1919,7 +1920,7 @@ async function fetchCustomOpenAIChat(
? (requestBody.stream_options as Record<string, unknown>)
: {};
const response = await globalThis.fetch(input, {
const response = await fetchImpl(input, {
...init,
body: JSON.stringify({
...requestBody,
@@ -2078,14 +2079,23 @@ export function getModel(config: ModelConfig): ModelWithInfo {
config.baseUrl || provider?.defaultBaseUrl || undefined,
);
// The outbound transport. resolveModel installs a redirect-validating fetch
// here so every hop of a request to a client-supplied base URL is re-checked;
// without one, requests go through the global fetch exactly as before
// (resolved at call time, so tests that stub it keep working).
const transportFetch: typeof fetch =
config.fetchImpl ?? ((fetchInput, fetchInit) => globalThis.fetch(fetchInput, fetchInit));
let model: LanguageModel;
switch (providerType) {
case 'azure': {
const azure = createAzure({
const azureOptions: Parameters<typeof createAzure>[0] = {
apiKey: effectiveApiKey,
baseURL: normalizeAzureBaseUrl(effectiveBaseUrl),
});
};
if (config.fetchImpl) azureOptions.fetch = config.fetchImpl;
const azure = createAzure(azureOptions);
model = azure(config.modelId);
break;
}
@@ -2154,8 +2164,8 @@ export function getModel(config: ModelConfig): ModelWithInfo {
}
}
const response = useStreamingChatCompat
? await fetchCustomOpenAIChat(url, init)
: await globalThis.fetch(url, init);
? await fetchCustomOpenAIChat(url, init, transportFetch)
: await transportFetch(url, init);
// Recover reasoning that @ai-sdk/openai's chat schema drops: rewrite
// streamed `reasoning_content` deltas into an inline <think> block
@@ -2213,6 +2223,10 @@ export function getModel(config: ModelConfig): ModelWithInfo {
return response;
};
openaiOptions.fetch = compatFetch as typeof globalThis.fetch;
} else if (config.fetchImpl) {
// Native OpenAI / Responses transport with a validated fetch installed
// by the server: still route requests through it.
openaiOptions.fetch = config.fetchImpl;
}
const openai = createOpenAI(openaiOptions);
@@ -2272,8 +2286,10 @@ export function getModel(config: ModelConfig): ModelWithInfo {
}
}
return globalThis.fetch(url, init);
return transportFetch(url, init);
}) as typeof globalThis.fetch;
} else if (config.fetchImpl) {
anthropicOptions.fetch = config.fetchImpl;
}
const anthropic = createAnthropic(anthropicOptions);
@@ -2317,6 +2333,8 @@ export function getModel(config: ModelConfig): ModelWithInfo {
});
return response as Response;
}) as typeof fetch;
} else if (config.fetchImpl) {
googleOptions.fetch = config.fetchImpl;
}
const google = createGoogleGenerativeAI(googleOptions);
model = google.chat(config.modelId);
+37
View File
@@ -17,6 +17,10 @@ import type { IncomingMessage } from 'node:http';
import type { AssetPrincipal } from '@openmaic/storage';
import type { RuntimeHttpPrincipal } from '@openmaic/storage/server';
import { createLogger } from '@/lib/logger';
const log = createLogger('PersistenceAuth');
type PersistencePrincipal = RuntimeHttpPrincipal & Partial<Pick<AssetPrincipal, 'key'>>;
/**
@@ -25,6 +29,37 @@ type PersistencePrincipal = RuntimeHttpPrincipal & Partial<Pick<AssetPrincipal,
*/
const SHARED_ASSET_PRINCIPAL = 'shared';
/**
* Whether the operator explicitly opted the development authenticator into
* production traffic (PERSISTENCE_ALLOW_INSECURE_DEV_AUTH=true/1). Both the
* startup warning and the runtime gate read the same opt-in through this one
* helper so the two copies of the parsing cannot drift.
*/
function insecureDevAuthOptInEnabled(): boolean {
const optIn = process.env.PERSISTENCE_ALLOW_INSECURE_DEV_AUTH;
return optIn === 'true' || optIn === '1';
}
/**
* The development authenticator must never serve production traffic unless the
* operator explicitly accepts the trade-off. This module provides no user
* isolation, so production defaults to refusing it entirely (returns undefined,
* which callers turn into a 401); PERSISTENCE_ALLOW_INSECURE_DEV_AUTH=true is
* the documented opt-in for trusted-network single-user deployments.
*/
function devAuthenticatorAllowedInCurrentEnvironment(): boolean {
if (process.env.NODE_ENV !== 'production') return true;
return insecureDevAuthOptInEnabled();
}
if (process.env.NODE_ENV === 'production' && insecureDevAuthOptInEnabled()) {
log.warn(
'Persistence is running the development authenticator in production: it provides no user ' +
'isolation, so this endpoint must only be reachable on a trusted network. Replace it with ' +
'real session verification before serving public traffic.',
);
}
function singleHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
@@ -39,6 +74,8 @@ function authenticatePersistenceCredentials(
authorization: string | undefined,
learnerKey: string | undefined,
): PersistencePrincipal | undefined {
if (!devAuthenticatorAllowedInCurrentEnvironment()) return undefined;
const token = process.env.PERSISTENCE_DEV_TOKEN;
if (!token || !authorization || !secureEqual(authorization, `Bearer ${token}`)) return undefined;
+18 -2
View File
@@ -45,8 +45,24 @@ export function isValidClassroomId(id: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(id);
}
/**
* Resolve the on-disk JSON path for a classroom id, asserting the result stays
* inside CLASSROOMS_DIR. The route validates ids up front, but storage must
* not trust callers: an id carrying path separators (e.g. `..`) must never be
* allowed to name a file outside the classrooms directory.
*/
export function resolveClassroomFilePath(id: string): string {
const resolvedRoot = path.resolve(CLASSROOMS_DIR);
const filePath = path.resolve(resolvedRoot, `${id}.json`);
const rootPrefix = `${resolvedRoot}${path.sep}`;
if (filePath !== resolvedRoot && !filePath.startsWith(rootPrefix)) {
throw new Error(`Classroom id "${id}" resolves outside the classrooms directory`);
}
return filePath;
}
export async function readClassroom(id: string): Promise<PersistedClassroomData | null> {
const filePath = path.join(CLASSROOMS_DIR, `${id}.json`);
const filePath = resolveClassroomFilePath(id);
try {
const content = await fs.readFile(filePath, 'utf-8');
return JSON.parse(content) as PersistedClassroomData;
@@ -73,8 +89,8 @@ export async function persistClassroom(
createdAt: new Date().toISOString(),
};
const filePath = resolveClassroomFilePath(data.id);
await ensureClassroomsDir();
const filePath = path.join(CLASSROOMS_DIR, `${data.id}.json`);
await writeJsonFileAtomic(filePath, classroomData);
return {
@@ -0,0 +1,163 @@
/**
* fetch() replacement that re-validates every redirect hop.
*
* The origin of an outbound request is checked once (by the caller, through
* `validateUrlForSSRF`) and then handed to Node's default fetch, which follows
* redirects on its own without ever re-checking the `Location` target. A host
* that resolves publicly can therefore answer `302 Location:
* http://<private-address>/...` and pull the request onto an internal network.
*
* This wrapper fetches with `redirect: 'manual'`, resolves each `Location`
* against the current URL, re-runs `validateUrlForSSRF` on the resolved
* target, and only then follows — mirroring the per-hop loop used by
* `app/api/proxy-media/route.ts` and the agent-runtime media downloads. Hops
* are bounded (5, matching those implementations). A rejected hop fails
* loudly with the guard's own message; the 3xx is never handed back as if it
* were a real response, and there is no unvalidated fallback.
*/
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
export const MAX_REDIRECT_HOPS = 5;
/**
* Request headers that carry provider credentials and must never cross an
* origin boundary when a redirect is followed manually. These are the header
* spellings the provider layer attaches to outbound calls: `authorization`
* (Bearer tokens from the OpenAI/Anthropic/Azure SDKs and the verify routes),
* `api-key` (Azure), `x-api-key` (Anthropic) and `x-goog-api-key` (Google).
* Matching is case-insensitive because HTTP header names are.
*/
const CREDENTIAL_HEADERS = new Set(['authorization', 'api-key', 'x-api-key', 'x-goog-api-key']);
function isCredentialHeader(name: string): boolean {
return CREDENTIAL_HEADERS.has(name.trim().toLowerCase());
}
/** Duck-typed Headers check so a Headers from any realm is recognized. */
function isHeadersInstance(value: unknown): value is Headers {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as Headers).forEach === 'function' &&
typeof (value as Headers).get === 'function' &&
typeof (value as Headers).delete === 'function'
);
}
function hasCredentialHeaders(headers: HeadersInit): boolean {
if (isHeadersInstance(headers)) {
for (const name of headers.keys()) {
if (isCredentialHeader(name)) return true;
}
return false;
}
if (Array.isArray(headers)) {
return headers.some(([name]) => isCredentialHeader(name));
}
return Object.keys(headers).some((name) => isCredentialHeader(name));
}
/**
* Return a copy of `headers` without the credential headers, preserving the
* original shape (`Headers` instance, string-pair array or plain object) so a
* same-shape header block reaches the next hop.
*/
function stripCredentialHeaders(headers: HeadersInit | undefined): HeadersInit | undefined {
if (!headers) return headers;
if (isHeadersInstance(headers)) {
const next = new Headers(headers);
for (const name of CREDENTIAL_HEADERS) next.delete(name);
return next;
}
if (Array.isArray(headers)) {
return headers.filter(([name]) => !isCredentialHeader(name));
}
const next: Record<string, string> = {};
for (const [name, value] of Object.entries(headers)) {
if (!isCredentialHeader(name)) next[name] = value;
}
return next;
}
/**
* A request body that is a stream is consumed by the hop that is being sent,
* so it cannot be replayed onto a redirect target. Anything that is neither a
* string nor a static buffer-like value counts as unreplayable.
*/
function isStreamBody(body: unknown): boolean {
if (!body || typeof body !== 'object') return false;
const candidate = body as {
getReader?: unknown;
pipe?: unknown;
[Symbol.asyncIterator]?: unknown;
};
return (
typeof candidate.getReader === 'function' ||
typeof candidate.pipe === 'function' ||
typeof candidate[Symbol.asyncIterator] === 'function'
);
}
function requestUrlString(input: RequestInfo | URL): string {
if (typeof input === 'string') return input;
if (input instanceof URL) return input.toString();
return input.url;
}
/**
* Fetch `input`, following at most {@link MAX_REDIRECT_HOPS} redirects and
* validating every hop target with {@link validateUrlForSSRF} before the next
* request is made. Resolves with the first non-redirect response.
*/
export async function fetchWithRedirectValidation(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
let currentUrl = requestUrlString(input);
// init of the hop about to be issued; credential headers may be removed
// from it before a cross-origin hop, never mutating the caller's init.
let hopInit: RequestInit | undefined = init;
for (let hop = 0; ; hop++) {
const response = await fetch(currentUrl, { ...hopInit, redirect: 'manual' });
if (response.status < 300 || response.status >= 400) return response;
const location = response.headers.get('location');
if (!location) throw new Error('Provider request redirected without a Location header');
if (hop >= MAX_REDIRECT_HOPS) {
throw new Error(`Provider request exceeded ${MAX_REDIRECT_HOPS} redirects`);
}
let nextUrl: string;
try {
nextUrl = new URL(location, currentUrl).href; // resolve relative redirects
} catch {
throw new Error('Provider request received an invalid redirect Location');
}
const ssrfError = await validateUrlForSSRF(nextUrl);
if (ssrfError) throw new Error(ssrfError);
// A streaming request body has been consumed by the request that just
// answered with a redirect and cannot be replayed; fail loudly instead of
// forwarding the next hop with an empty body.
if (isStreamBody(hopInit?.body)) {
throw new Error(
'Provider request cannot follow a redirect: its streaming request body cannot be replayed',
);
}
// Credentials are scoped to the origin that issued them. The platform
// fetch drops Authorization itself on a cross-origin redirect; mirror that
// here so provider keys are not forwarded to a different origin.
// Same-origin hops keep their headers untouched.
if (
new URL(nextUrl).origin !== new URL(currentUrl).origin &&
hopInit?.headers &&
hasCredentialHeaders(hopInit.headers)
) {
hopInit = { ...hopInit, headers: stripCredentialHeaders(hopInit.headers) };
}
currentUrl = nextUrl;
}
}
+12 -4
View File
@@ -15,6 +15,7 @@ import {
resolveProxy,
} from '@/lib/server/provider-config';
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
import { fetchWithRedirectValidation } from '@/lib/server/fetch-with-redirect-validation';
import { getStageRoute, type LlmStage } from '@/lib/server/model-routes';
export interface ResolvedModel extends ModelWithInfo {
@@ -80,9 +81,13 @@ export async function resolveModel(params: {
const clientBaseUrlParam = routed ? undefined : params.baseUrl;
// Server-managed providers are admin-owned: the operator's key and base URL
// are authoritative and any client-sent override is ignored. SSRF validation
// therefore applies only to unmanaged providers, where the base URL really is
// client-supplied. (Server-configured URLs are trusted by the operator.)
// are authoritative and any client-sent override is ignored. Origin URL
// validation therefore applies only to unmanaged providers, where the base
// URL really is client-supplied. (Server-configured URLs are trusted by the
// operator.) Every provider fetch still runs through a transport that
// re-validates redirect hops: no upstream can be assumed to redirect only to
// public targets, so the hop target is checked regardless of who chose the
// origin.
const managed = isServerConfiguredProvider('providers', providerId);
const registeredProviderType = getProvider(providerId)?.type;
if (
@@ -101,7 +106,7 @@ export async function resolveModel(params: {
throw new Error('Amazon Bedrock must be enabled by the server operator before it can be used.');
}
const clientBaseUrl = managed ? undefined : clientBaseUrlParam || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
if (clientBaseUrl) {
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
throw new Error(ssrfError);
@@ -118,6 +123,9 @@ export async function resolveModel(params: {
baseUrl,
proxy,
providerType: clientProviderType as ProviderType | undefined,
// Re-validate every redirect hop of the outbound request (see
// fetchWithRedirectValidation); the base URL above is checked at origin.
fetchImpl: fetchWithRedirectValidation,
});
// Thinking arbitration mirrors model routing — the route carries a full
+301
View File
@@ -0,0 +1,301 @@
/**
* Scene-content HTML sanitization for the classroom persistence boundary.
*
* INVARIANT: stored slide HTML is restricted to the formatting vocabulary the
* renderer produces. Slide element content is authored as ProseMirror HTML and
* is later injected into the DOM verbatim (`dangerouslySetInnerHTML` on the
* `ProseMirror-static` containers), so anything allowed through here must be
* exactly the set of tags / classes / inline styles the editor schemas and the
* KaTeX renderer emit — nothing more. Everything executable (script tags,
* event-handler attributes, `javascript:` URLs, embedded-object tags) is
* outside that vocabulary and is removed here, once, at the boundary where
* classroom content enters or leaves storage, instead of at each render sink.
*
* The allowlists below were derived empirically:
* - the ProseMirror schemas in `lib/prosemirror/schema` and in
* `@openmaic/editor`'s text schema (marks/nodes → their `toDOM` output),
* - the renderer text/shape/table element components,
* - real stage/scene fixtures in `eval/`, `tests/` and the
* `@openmaic/editor` round-trip tests,
* - actual KaTeX HTML snapshots rendered with the repo's `katex`.
*
* LaTeX elements persist a `html` snapshot produced by
* `katex.renderToString(..., { output: 'html' })` (see
* `lib/edit/slide-edit-elements.ts`); that output is spans + layout SVGs and
* needs its own policy so formulas are not flattened.
*/
import sanitizeHtml, { type IOptions } from 'sanitize-html';
// ---------------------------------------------------------------------------
// Allowlist primitives
// ---------------------------------------------------------------------------
/**
* Inline styles are kept by *property* (colors, sizes, font families,
* alignment, indentation, list markers, …) rather than wholesale. Values are
* constrained to plain CSS tokens: no `url(...)`, no `expression(...)`, no
* `@`-rules, no CSS-embedded script schemes. The individual properties a
* renderer may emit are enumerated below — a property not in the list is
* dropped, so a stray `background: url(...)` or `behavior: url(...)` cannot
* ride in through the `style` attribute.
*/
const SAFE_CSS_VALUE =
/^(?!.*(?:url\s*\(|expression\s*\(|@|behavior\s*:|-moz-binding\s*:|vbscript:|javascript:))[^<>{};]{0,240}$/i;
/**
* Every CSS property the ProseMirror schemas, the PPTX text importer and the
* KaTeX HTML snapshot emit inline. KaTeX additionally lays out spans with
* `position`/`top`/`left`/`bottom`/`right`; prose content never does, so those
* stay out of the prose policy.
*/
const STYLE_PROPERTIES = [
'color',
'background-color',
'font-size',
'font-family',
'font-weight',
'font-style',
'text-align',
'text-indent',
'text-decoration',
'text-decoration-line',
'text-decoration-style',
'text-transform',
'letter-spacing',
'word-spacing',
'line-height',
'vertical-align',
'display',
'width',
'height',
'min-width',
'max-width',
'min-height',
'max-height',
'margin',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'padding',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'white-space',
'box-sizing',
'list-style-type',
'word-break',
'word-wrap',
'border',
'border-top',
'border-right',
'border-bottom',
'border-left',
'border-width',
'border-top-width',
'border-right-width',
'border-bottom-width',
'border-left-width',
'border-style',
'border-top-style',
'border-right-style',
'border-bottom-style',
'border-left-style',
'border-color',
'border-top-color',
'border-right-color',
'border-bottom-color',
'border-left-color',
] as const;
/** Positioning geometry: used only by KaTeX layout spans, never by prose. */
const LAYOUT_STYLE_PROPERTIES = ['top', 'bottom', 'left', 'right', 'position'] as const;
const LATEX_STYLE_PROPERTIES: readonly string[] = [...STYLE_PROPERTIES, ...LAYOUT_STYLE_PROPERTIES];
function styleRules(properties: readonly string[]): Record<string, RegExp[]> {
const rules: Record<string, RegExp[]> = {};
for (const property of properties) {
rules[property] = [SAFE_CSS_VALUE];
}
return rules;
}
/** `allowedStyles` shape: per-tag style maps; `'*'` applies the rules to every tag. */
function allowedStylesByTag(
properties: readonly string[],
): Record<string, Record<string, RegExp[]>> {
return { '*': styleRules(properties) };
}
// ---------------------------------------------------------------------------
// Prose policy — text elements, shape text and table cell text
// ---------------------------------------------------------------------------
/**
* HTML vocabulary of text-element content (ProseMirror serialized HTML plus
* the legacy/imported constructs the renderer still understands).
*/
export const PROSE_ALLOWED_TAGS = [
'p',
'br',
'div',
'blockquote',
'ol',
'ul',
'li',
'a',
'span',
'strong',
'em',
'b',
'i',
'u',
's',
'strike',
'sub',
'sup',
'code',
'mark',
// Structural table tags: keep legacy cell markup that a stored cell/body may
// still carry so a nested table renders instead of being flattened.
'table',
'thead',
'tbody',
'tfoot',
'tr',
'td',
'th',
'caption',
'colgroup',
'col',
] as const;
const PROSE_ATTRIBUTES: Record<string, string[]> = {
'*': ['class', 'style'],
a: ['href', 'title', 'target', 'rel', 'name'],
p: ['align', 'data-indent'],
ol: ['start'],
mark: ['data-index'],
td: ['colspan', 'rowspan'],
th: ['colspan', 'rowspan'],
};
const PROSE_OPTIONS: IOptions = {
allowedTags: [...PROSE_ALLOWED_TAGS],
allowedAttributes: PROSE_ATTRIBUTES,
allowedStyles: allowedStylesByTag(STYLE_PROPERTIES),
};
/**
* Sanitize one prose-HTML string (a text element's `content`, a shape's
* `text.content`, or a table cell's `text`).
*/
export function sanitizeProseHtml(html: string): string {
return sanitizeHtml(html, PROSE_OPTIONS);
}
// ---------------------------------------------------------------------------
// LaTeX policy — KaTeX-rendered `html` snapshots on latex elements
// ---------------------------------------------------------------------------
/**
* KaTeX `output: 'html'` emits nested `<span class="…" style="…">` nodes plus
* layout `<svg>/<path>/<line>` fragments (radicals, stretchy delimiters,
* cancel rules). Keep exactly that vocabulary; classes and presentation
* attributes are inert, so handlers/scripts cannot ride in through them.
*
* sanitize-html lowercases attribute names, and KaTeX's layout SVG relies on
* the camelCase `viewBox` / `preserveAspectRatio` attributes, so after
* sanitization those two names are restored to their authored case (the only
* place they can appear is a layout SVG, where they are inert geometry).
*/
const LATEX_ALLOWED_TAGS = ['span', 'svg', 'path', 'line'] as const;
const LATEX_ATTRIBUTES: Record<string, string[]> = {
'*': ['class', 'style'],
span: ['aria-hidden'],
svg: ['xmlns', 'width', 'height', 'viewbox', 'preserveaspectratio', 'aria-hidden'],
path: ['d'],
line: ['x1', 'y1', 'x2', 'y2', 'stroke-width'],
};
const LATEX_OPTIONS: IOptions = {
allowedTags: [...LATEX_ALLOWED_TAGS],
allowedAttributes: LATEX_ATTRIBUTES,
allowedStyles: allowedStylesByTag(LATEX_STYLE_PROPERTIES),
};
/**
* Sanitize the KaTeX HTML snapshot stored on a latex element. This strips
* anything the renderer could not have produced (scripts, event handlers,
* embedded frames) while keeping the spans / layout SVG that make a formula
* render.
*/
export function sanitizeLatexHtml(html: string): string {
return sanitizeHtml(html, LATEX_OPTIONS)
.replace(/viewbox=/g, 'viewBox=')
.replace(/preserveaspectratio=/g, 'preserveAspectRatio=');
}
// ---------------------------------------------------------------------------
// Payload walker — sanitize every HTML-bearing string in a stage/scene payload
// ---------------------------------------------------------------------------
type JsonRecord = Record<string, unknown>;
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function sanitizeCell(row: unknown): unknown {
if (!Array.isArray(row)) return row;
return row.map((cell) => {
if (!isRecord(cell) || typeof cell.text !== 'string') return cell;
return { ...cell, text: sanitizeProseHtml(cell.text) };
});
}
/**
* Apply the prose/LaTeX policies to the HTML-bearing fields of one slide
* element object (by its DSL `type` discriminant), then recurse into the
* remaining fields. Text, shape and table cells use the prose vocabulary;
* latex uses the KaTeX snapshot policy. Code elements store plain text lines —
* deliberately NOT treated as HTML, so code like `a < b` is never escaped.
*/
function sanitizeValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sanitizeValue);
if (!isRecord(value)) return value;
const out: JsonRecord = {};
for (const [key, child] of Object.entries(value)) {
out[key] = sanitizeValue(child);
}
if (typeof out.type !== 'string') return out;
if (out.type === 'text' && typeof out.content === 'string') {
out.content = sanitizeProseHtml(out.content);
} else if (out.type === 'shape') {
if (isRecord(out.text) && typeof out.text.content === 'string') {
out.text = { ...out.text, content: sanitizeProseHtml(out.text.content) };
}
} else if (out.type === 'table' && Array.isArray(out.data)) {
out.data = out.data.map(sanitizeCell);
} else if (out.type === 'latex' && typeof out.html === 'string') {
out.html = sanitizeLatexHtml(out.html);
}
return out;
}
/**
* Return a copy of a classroom stage/scene payload with every HTML-bearing
* string sanitized to the renderer's formatting vocabulary. Safe to run twice
* (idempotent): the read path applies it over content stored before this
* change existed, and the write path applies it to new payloads.
*/
export function sanitizeSceneContent<T>(payload: T): T {
return sanitizeValue(payload) as T;
}
+6
View File
@@ -190,4 +190,10 @@ export interface ModelConfig {
baseUrl?: string;
proxy?: string; // Optional: HTTP proxy URL for this provider
providerType?: ProviderType; // Optional: for custom providers on server-side
/**
* Optional server-side fetch implementation used for the model's outbound
* requests (e.g. a wrapper that re-validates redirect hops). When omitted the
* global fetch is used. Never set by client-side consumers.
*/
fetchImpl?: typeof fetch;
}
+32 -2
View File
@@ -468,8 +468,8 @@ describe('POST /api/extract-document (asset-id form)', () => {
expect(mocks.resolveServerAsset).not.toHaveBeenCalled();
});
it('applies the SSRF guard to the JSON path baseUrl in production mode', async () => {
vi.stubEnv('NODE_ENV', 'production');
it('rejects a client-supplied JSON path baseUrl pointing at a metadata address in any environment', async () => {
vi.stubEnv('NODE_ENV', 'development');
vi.stubEnv('ALLOW_LOCAL_NETWORKS', 'false');
mocks.resolveServerAsset.mockResolvedValue({
status: 'resolved',
@@ -494,6 +494,36 @@ describe('POST /api/extract-document (asset-id form)', () => {
expect(mocks.parseWithMinerUCloud).not.toHaveBeenCalled();
});
it('lets the JSON path proceed when ALLOW_LOCAL_NETWORKS=true opts a local base URL in', async () => {
vi.stubEnv('NODE_ENV', 'development');
vi.stubEnv('ALLOW_LOCAL_NETWORKS', 'true');
mocks.resolveServerAsset.mockResolvedValue({
status: 'resolved',
buffer: Buffer.from('%PDF-1.4'),
mimeType: 'application/pdf',
});
const res = await postExtractDocumentByAssetId({
assetId: 'ast_abc',
fileName: 'lesson.pdf',
mimeType: 'application/pdf',
providerId: 'mineru-cloud',
baseUrl: 'http://169.254.169.254/latest/meta-data/',
});
const json = await res.json();
expect(res.status).toBe(200);
expect(json).toMatchObject({ success: true });
expect(mocks.parseWithMinerUCloud).toHaveBeenCalledWith(
expect.objectContaining({
providerId: 'mineru-cloud',
baseUrl: 'http://169.254.169.254/latest/meta-data/',
}),
expect.any(Buffer),
'lesson.pdf',
);
});
it('returns 413 when the resolved server asset exceeds the 50 MB cap (post-resolve backstop)', async () => {
mocks.resolveServerAsset.mockResolvedValue({
status: 'resolved',
+45
View File
@@ -50,3 +50,48 @@ describe('embedded persistence development authentication', () => {
).resolves.toBeUndefined();
});
});
describe('embedded persistence development authentication — production gate', () => {
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv('PERSISTENCE_DEV_TOKEN', 'shared-secret');
vi.stubEnv('NODE_ENV', 'development');
vi.stubEnv('PERSISTENCE_ALLOW_INSECURE_DEV_AUTH', '');
});
it('refuses the development authenticator in production without the explicit opt-in', async () => {
vi.stubEnv('NODE_ENV', 'production');
await expect(
authenticatePersistenceRequest(
request({ authorization: 'Bearer shared-secret', 'x-learner-key': 'anon:learner-1' }),
),
).resolves.toBeUndefined();
});
it('serves in production when the insecure-opt-in is explicitly set', async () => {
vi.stubEnv('NODE_ENV', 'production');
vi.stubEnv('PERSISTENCE_ALLOW_INSECURE_DEV_AUTH', 'true');
await expect(
authenticatePersistenceRequest(
request({ authorization: 'Bearer shared-secret', 'x-learner-key': 'anon:learner-1' }),
),
).resolves.toEqual({ key: 'shared', learnerKey: 'anon:learner-1' });
});
it('keeps unchanged behaviour outside production regardless of the opt-in flag', async () => {
vi.stubEnv('NODE_ENV', 'development');
vi.stubEnv('PERSISTENCE_ALLOW_INSECURE_DEV_AUTH', 'true');
await expect(
authenticatePersistenceRequest(
request({ authorization: 'Bearer shared-secret', 'x-learner-key': 'anon:dev-1' }),
),
).resolves.toEqual({ key: 'shared', learnerKey: 'anon:dev-1' });
vi.stubEnv('PERSISTENCE_ALLOW_INSECURE_DEV_AUTH', '');
await expect(
authenticatePersistenceRequest(
request({ authorization: 'Bearer shared-secret', 'x-learner-key': 'anon:dev-2' }),
),
).resolves.toEqual({ key: 'shared', learnerKey: 'anon:dev-2' });
});
});
@@ -0,0 +1,241 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// POST /api/classroom must reject payloads whose scenes are not DSL-shaped
// (400, before persistence) and must sanitize HTML-bearing element content
// before it reaches storage; GET must run the same sanitizer on content that
// was stored before this change.
const mocks = vi.hoisted(() => ({
persistClassroom: vi.fn(),
readClassroom: vi.fn(),
}));
vi.mock('@/lib/server/classroom-storage', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/server/classroom-storage')>();
return {
...actual,
persistClassroom: mocks.persistClassroom,
readClassroom: mocks.readClassroom,
};
});
vi.mock('@/lib/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
function slideScene(element?: Record<string, unknown>): Record<string, unknown> {
return {
id: 'scene-1',
stageId: 'abc-123_XY',
title: 'Scene 1',
order: 0,
type: 'slide',
content: {
type: 'slide',
canvas: {
id: 'slide-1',
viewportSize: 1000,
viewportRatio: 0.5625,
theme: {
backgroundColor: '#ffffff',
themeColors: ['#5b9bd5'],
fontColor: '#333333',
fontName: 'Microsoft YaHei',
},
elements: element ? [element] : [],
},
},
};
}
const stage = { id: 'abc-123_XY', name: 'Lesson', createdAt: 0, updatedAt: 0 };
function postClassroom(body: unknown) {
return new NextRequest('http://localhost/api/classroom', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}
describe('POST /api/classroom — DSL shape validation', () => {
beforeEach(() => {
vi.resetModules();
mocks.persistClassroom.mockReset();
mocks.readClassroom.mockReset();
mocks.persistClassroom.mockImplementation(async ({ id }: { id: string }) => ({
id,
url: `http://localhost/classroom/${id}`,
}));
});
it('rejects a body whose scenes is not an array with a 400 and never persists', async () => {
const { POST } = await import('@/app/api/classroom/route');
const res = await POST(postClassroom({ stage, scenes: 'not-an-array' }));
const json = await res.json();
expect(res.status).toBe(400);
expect(json).toMatchObject({
success: false,
errorCode: 'INVALID_REQUEST',
error: 'Invalid classroom scenes: must be an array',
});
expect(mocks.persistClassroom).not.toHaveBeenCalled();
});
it('rejects a scene that does not have the shape the DSL declares with a 400', async () => {
const { POST } = await import('@/app/api/classroom/route');
const res = await POST(
postClassroom({
stage,
scenes: [{ id: 'scene-1', type: 'slide', content: { type: 'slide' } }],
}),
);
const json = await res.json();
expect(res.status).toBe(400);
expect(json).toMatchObject({
success: false,
errorCode: 'INVALID_REQUEST',
error: 'Invalid classroom scene at index 0',
});
expect(typeof json.details).toBe('string');
expect(mocks.persistClassroom).not.toHaveBeenCalled();
});
it('rejects an unknown scene type with a 400', async () => {
const { POST } = await import('@/app/api/classroom/route');
const res = await POST(
postClassroom({
stage,
scenes: [
{
id: 'scene-1',
stageId: 'abc-123_XY',
title: 'Scene 1',
order: 0,
type: 'holodeck',
content: { type: 'holodeck' },
},
],
}),
);
const json = await res.json();
expect(res.status).toBe(400);
expect(json).toMatchObject({
success: false,
errorCode: 'INVALID_REQUEST',
error: 'Invalid classroom scene at index 0',
});
expect(mocks.persistClassroom).not.toHaveBeenCalled();
});
});
describe('POST /api/classroom — sanitization before persistence', () => {
beforeEach(() => {
vi.resetModules();
mocks.persistClassroom.mockReset();
mocks.readClassroom.mockReset();
mocks.persistClassroom.mockImplementation(async ({ id }: { id: string }) => ({
id,
url: `http://localhost/classroom/${id}`,
}));
});
it('persists sanitized element content: no handler survives, markup stays', async () => {
const { POST } = await import('@/app/api/classroom/route');
const res = await POST(
postClassroom({
stage,
scenes: [
slideScene({
type: 'text',
id: 'el-1',
content:
'<p style="color:#ff0000">Keep <strong>this</strong></p><p><img src="x" onerror="alert(1)"><script>alert(2)</script>tail</p>',
left: 50,
top: 50,
width: 900,
height: 100,
rotate: 0,
defaultFontName: 'Microsoft YaHei',
defaultColor: '#333333',
}),
],
}),
);
expect(res.status).toBe(201);
expect(mocks.persistClassroom).toHaveBeenCalledTimes(1);
const [persisted] = mocks.persistClassroom.mock.calls[0];
const scene = persisted.scenes[0] as {
content: { canvas: { elements: Array<{ content: string }> } };
};
const content = scene.content.canvas.elements[0].content;
expect(content).not.toContain('onerror');
expect(content).not.toContain('<script');
expect(content).not.toContain('<img');
expect(content).toContain('<strong>this</strong>');
expect(content).toContain('color:#ff0000');
expect(content).toContain('tail');
});
});
describe('GET /api/classroom — legacy stored content is cleaned on the way out', () => {
beforeEach(() => {
vi.resetModules();
mocks.persistClassroom.mockReset();
mocks.readClassroom.mockReset();
});
it('serves sanitized content for classrooms stored before this change', async () => {
mocks.readClassroom.mockResolvedValue({
id: 'abc-123_XY',
createdAt: '2024-01-01T00:00:00.000Z',
stage,
scenes: [
slideScene({
type: 'text',
id: 'el-1',
content: '<p>legacy <strong>bold</strong></p><p><img src=x onerror="alert(1)">x</p>',
left: 50,
top: 50,
width: 900,
height: 100,
rotate: 0,
defaultFontName: 'Microsoft YaHei',
defaultColor: '#333333',
}),
],
});
const { GET } = await import('@/app/api/classroom/route');
const request = new NextRequest('http://localhost/api/classroom?id=abc-123_XY');
const res = await GET(request);
const json = await res.json();
expect(res.status).toBe(200);
expect(json.success).toBe(true);
const classroom = json.classroom as {
scenes: Array<{ content: { canvas: { elements: Array<{ content: string }> } } }>;
};
const content = classroom.scenes[0].content.canvas.elements[0].content;
expect(content).not.toContain('onerror');
expect(content).not.toContain('<img');
expect(content).toContain('<strong>bold</strong>');
expect(content).toContain('legacy');
});
});
+135
View File
@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// POST /api/classroom must reject an id that would escape the classrooms
// directory before any persistence happens, and must keep accepting generated
// uuids and ordinary allowlisted ids.
const mocks = vi.hoisted(() => ({
persistClassroom: vi.fn(),
readClassroom: vi.fn(),
}));
vi.mock('@/lib/server/classroom-storage', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/server/classroom-storage')>();
return {
...actual,
persistClassroom: mocks.persistClassroom,
readClassroom: mocks.readClassroom,
};
});
vi.mock('@/lib/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
function postClassroom(stage: Record<string, unknown>, scenes: unknown[] = []) {
const request = new NextRequest('http://localhost/api/classroom', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ stage, scenes }),
});
return request;
}
describe('POST /api/classroom — id validation before persistence', () => {
beforeEach(() => {
vi.resetModules();
mocks.persistClassroom.mockReset();
mocks.readClassroom.mockReset();
mocks.persistClassroom.mockImplementation(async ({ id }: { id: string }) => ({
id,
url: `http://localhost/classroom/${id}`,
}));
});
it('returns 400 for a traversal-style stage id and never persists', async () => {
const { POST } = await import('@/app/api/classroom/route');
const res = await POST(
postClassroom({
id: '../../../../tmp/openmaic-escape',
title: 'Lesson',
}),
);
const json = await res.json();
expect(res.status).toBe(400);
expect(json).toMatchObject({
success: false,
errorCode: 'INVALID_REQUEST',
error: 'Invalid classroom id',
});
expect(mocks.persistClassroom).not.toHaveBeenCalled();
});
it('accepts an omitted stage id and persists with a generated uuid', async () => {
const { POST } = await import('@/app/api/classroom/route');
const res = await POST(
postClassroom(
{
title: 'Lesson',
type: 'slide',
},
[
{
id: 'scene-1',
stageId: 'classroom-1',
title: 'Scene 1',
order: 0,
type: 'slide',
content: { type: 'slide', canvas: {} },
},
],
),
);
const json = await res.json();
expect(res.status).toBe(201);
expect(json).toMatchObject({ success: true });
expect(typeof json.id).toBe('string');
expect(mocks.persistClassroom).toHaveBeenCalledTimes(1);
const [persisted] = mocks.persistClassroom.mock.calls[0];
expect(persisted.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
expect(persisted.stage.id).toBe(persisted.id);
});
it('still persists an ordinary allowlisted id', async () => {
const { POST } = await import('@/app/api/classroom/route');
const res = await POST(
postClassroom(
{
id: 'abc-123_XY',
title: 'Lesson',
},
[
{
id: 'scene-1',
stageId: 'abc-123_XY',
title: 'Scene 1',
order: 0,
type: 'slide',
content: { type: 'slide', canvas: {} },
},
],
),
);
const json = await res.json();
expect(res.status).toBe(201);
expect(json).toMatchObject({ success: true, id: 'abc-123_XY' });
expect(mocks.persistClassroom).toHaveBeenCalledWith(
expect.objectContaining({ id: 'abc-123_XY' }),
'http://localhost',
);
});
});
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import path from 'path';
import { CLASSROOMS_DIR, resolveClassroomFilePath } from '@/lib/server/classroom-storage';
describe('resolveClassroomFilePath — containment inside CLASSROOMS_DIR', () => {
it('throws for a traversal-style id', () => {
expect(() => resolveClassroomFilePath('../../../../tmp/openmaic-escape')).toThrow(
/outside the classrooms directory/,
);
});
it('throws for an absolute-style id', () => {
expect(() => resolveClassroomFilePath('/tmp/openmaic-escape')).toThrow(
/outside the classrooms directory/,
);
});
it('resolves an ordinary id to a path inside CLASSROOMS_DIR', () => {
expect(resolveClassroomFilePath('abc-123_XY')).toBe(
path.join(path.resolve(CLASSROOMS_DIR), 'abc-123_XY.json'),
);
});
});
@@ -0,0 +1,311 @@
/**
* Per-hop redirect re-validation for provider fetches.
*
* `fetchWithRedirectValidation` fetches with `redirect: 'manual'` and re-runs
* `validateUrlForSSRF` on every resolved `Location` before following it, so an
* origin that answers `302` to a loopback/private/metadata address never gets
* its redirect followed. The origin itself is assumed to have been validated
* by the caller; the escape hatch (ALLOW_LOCAL_NETWORKS=true) still permits
* redirects to local targets. DNS lookups are stubbed exactly like the
* ssrf-guard tests so hostname classification is exercised end to end.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { lookupMock } = vi.hoisted(() => ({
lookupMock: vi.fn(),
}));
vi.mock('node:dns', () => ({
promises: {
lookup: lookupMock,
},
}));
async function loadWrapper() {
return import('@/lib/server/fetch-with-redirect-validation');
}
describe('fetchWithRedirectValidation — every redirect hop is re-validated', () => {
beforeEach(() => {
vi.resetModules();
lookupMock.mockReset();
delete process.env.ALLOW_LOCAL_NETWORKS;
// A hostname used as a safe redirect target resolves publicly.
lookupMock.mockImplementation(async (hostname: string) => {
if (hostname === 'cdn.public.example') {
return [{ address: '93.184.216.34', family: 4 }];
}
throw new Error(`ENOTFOUND ${hostname}`);
});
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.ALLOW_LOCAL_NETWORKS;
});
it('rejects a public origin that answers 302 to a loopback address and never fetches the target', async () => {
const { fetchWithRedirectValidation } = await loadWrapper();
const fetchMock = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: 'http://127.0.0.1:8080/internal' },
}),
);
vi.stubGlobal('fetch', fetchMock);
await expect(
fetchWithRedirectValidation('https://api.public.example/v1/chat/completions', {
method: 'POST',
body: '{}',
}),
).rejects.toThrow(/Local\/private network URLs are not allowed/);
expect(fetchMock).toHaveBeenCalledTimes(1);
const requestedUrls = fetchMock.mock.calls.map(([input]) => String(input));
expect(requestedUrls).toEqual(['https://api.public.example/v1/chat/completions']);
expect(requestedUrls.join(' ')).not.toContain('127.0.0.1');
});
it('follows a 302 to another public address and returns the final response', async () => {
const { fetchWithRedirectValidation } = await loadWrapper();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(null, {
status: 302,
headers: { location: 'https://cdn.public.example/v1/chat/completions' },
}),
)
.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const response = await fetchWithRedirectValidation(
'https://api.public.example/v1/chat/completions',
);
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe('{"ok":true}');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(String(fetchMock.mock.calls[1][0])).toBe(
'https://cdn.public.example/v1/chat/completions',
);
// Every hop is fetched with manual redirect handling.
for (const [, init] of fetchMock.mock.calls) {
expect((init as RequestInit).redirect).toBe('manual');
}
expect(lookupMock).toHaveBeenCalledWith('cdn.public.example', { all: true, verbatim: true });
});
it('fails a redirect chain longer than the hop limit', async () => {
const { fetchWithRedirectValidation, MAX_REDIRECT_HOPS } = await loadWrapper();
const fetchMock = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: 'https://cdn.public.example/again' },
}),
);
vi.stubGlobal('fetch', fetchMock);
await expect(
fetchWithRedirectValidation('https://api.public.example/v1/chat/completions'),
).rejects.toThrow(`Provider request exceeded ${MAX_REDIRECT_HOPS} redirects`);
// origin + MAX_REDIRECT_HOPS followed hops, then the extra redirect errors.
expect(fetchMock).toHaveBeenCalledTimes(MAX_REDIRECT_HOPS + 1);
});
it('still permits a redirect to a local address when ALLOW_LOCAL_NETWORKS=true', async () => {
process.env.ALLOW_LOCAL_NETWORKS = 'true';
const { fetchWithRedirectValidation } = await loadWrapper();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(null, {
status: 302,
headers: { location: 'http://127.0.0.1:8080/internal' },
}),
)
.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const response = await fetchWithRedirectValidation(
'https://api.public.example/v1/chat/completions',
);
expect(response.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(String(fetchMock.mock.calls[1][0])).toBe('http://127.0.0.1:8080/internal');
});
it('drops credential headers before a cross-origin hop when init.headers is a Headers instance, keeping the other headers', async () => {
const { fetchWithRedirectValidation } = await loadWrapper();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(null, {
status: 302,
headers: { location: 'https://cdn.public.example/v1/chat/completions' },
}),
)
.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const headers = new Headers({
authorization: 'Bearer sk-test',
'x-api-key': 'anthropic-key',
'content-type': 'application/json',
});
const response = await fetchWithRedirectValidation(
'https://api.public.example/v1/chat/completions',
{ method: 'POST', headers, body: '{}' },
);
expect(response.status).toBe(200);
const firstInit = fetchMock.mock.calls[0][1] as RequestInit;
expect((firstInit.headers as Headers).get('authorization')).toBe('Bearer sk-test');
const secondHeaders = (fetchMock.mock.calls[1][1] as RequestInit).headers as Headers;
expect(secondHeaders).toBeInstanceOf(Headers);
expect(secondHeaders.get('authorization')).toBeNull();
expect(secondHeaders.get('x-api-key')).toBeNull();
expect(secondHeaders.get('content-type')).toBe('application/json');
// The caller's Headers instance is never mutated.
expect(headers.get('authorization')).toBe('Bearer sk-test');
});
it('drops credential headers before a cross-origin hop when init.headers is an array of string pairs', async () => {
const { fetchWithRedirectValidation } = await loadWrapper();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(null, {
status: 302,
headers: { location: 'https://cdn.public.example/v1/chat/completions' },
}),
)
.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const response = await fetchWithRedirectValidation(
'https://api.public.example/v1/chat/completions',
{
method: 'POST',
headers: [
['authorization', 'Bearer sk-test'],
['api-key', 'azure-key'],
['content-type', 'application/json'],
],
body: '{}',
},
);
expect(response.status).toBe(200);
expect((fetchMock.mock.calls[0][1] as RequestInit).headers).toEqual([
['authorization', 'Bearer sk-test'],
['api-key', 'azure-key'],
['content-type', 'application/json'],
]);
expect((fetchMock.mock.calls[1][1] as RequestInit).headers).toEqual([
['content-type', 'application/json'],
]);
});
it('drops every credential spelling case-insensitively from a plain-object header block on a cross-origin hop', async () => {
const { fetchWithRedirectValidation } = await loadWrapper();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(null, {
status: 302,
headers: { location: 'https://cdn.public.example/v1/chat/completions' },
}),
)
.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const headers: Record<string, string> = {
AUTHORIZATION: 'Bearer sk-test',
'Api-Key': 'azure-key',
'X-Api-Key': 'anthropic-key',
'X-GOOG-API-KEY': 'google-key',
'content-type': 'application/json',
};
const response = await fetchWithRedirectValidation(
'https://api.public.example/v1/chat/completions',
{ method: 'POST', headers, body: '{}' },
);
expect(response.status).toBe(200);
expect((fetchMock.mock.calls[0][1] as RequestInit).headers).toEqual(headers);
expect((fetchMock.mock.calls[1][1] as RequestInit).headers).toEqual({
'content-type': 'application/json',
});
// The caller's plain-object headers are never mutated.
expect(headers.AUTHORIZATION).toBe('Bearer sk-test');
});
it('keeps credential headers on a same-origin redirect (different path, same host)', async () => {
const { fetchWithRedirectValidation } = await loadWrapper();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(null, {
status: 302,
headers: { location: 'https://cdn.public.example/v2/chat/completions' },
}),
)
.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const response = await fetchWithRedirectValidation(
'https://cdn.public.example/v1/chat/completions',
{
method: 'POST',
headers: { authorization: 'Bearer sk-test', 'content-type': 'application/json' },
body: '{}',
},
);
expect(response.status).toBe(200);
expect((fetchMock.mock.calls[0][1] as RequestInit).headers).toEqual({
authorization: 'Bearer sk-test',
'content-type': 'application/json',
});
expect((fetchMock.mock.calls[1][1] as RequestInit).headers).toEqual({
authorization: 'Bearer sk-test',
'content-type': 'application/json',
});
});
it('fails clearly when a streaming request body cannot be replayed onto a redirect target', async () => {
const { fetchWithRedirectValidation } = await loadWrapper();
const fetchMock = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: 'https://cdn.public.example/v1/chat/completions' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{}'));
controller.close();
},
});
await expect(
fetchWithRedirectValidation('https://api.public.example/v1/chat/completions', {
method: 'POST',
headers: { authorization: 'Bearer sk-test' },
body: body as BodyInit,
}),
).rejects.toThrow(/cannot be replayed/);
// Only the origin request is issued; no follow-up request carries the
// consumed stream as an empty body.
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// A client-supplied provider base URL must be validated in every environment,
// not only when NODE_ENV === 'production'. The self-hosting escape hatch is
// ALLOW_LOCAL_NETWORKS, which the guard itself honors.
const mocks = vi.hoisted(() => ({
generateImage: vi.fn(),
}));
vi.mock('@/lib/media/image-providers', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/media/image-providers')>();
return {
...actual,
generateImage: mocks.generateImage,
};
});
vi.mock('@/lib/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
const IMAGE_ENV_PREFIXES = [
'IMAGE_OPENAI',
'IMAGE_SEEDREAM',
'IMAGE_QWEN_IMAGE',
'IMAGE_NANO_BANANA',
'IMAGE_MINIMAX',
'IMAGE_GROK',
'IMAGE_LEMONADE',
'IMAGE_COMFYUI',
];
function clearImageEnv() {
for (const prefix of IMAGE_ENV_PREFIXES) {
delete process.env[`${prefix}_API_KEY`];
delete process.env[`${prefix}_BASE_URL`];
delete process.env[`${prefix}_MODELS`];
delete process.env[`${prefix}_ENABLED`];
}
}
function imageRequest(headers: Record<string, string> = {}): NextRequest {
return new NextRequest('http://localhost/api/generate/image', {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify({ prompt: 'a cat' }),
});
}
describe('generate image — client-supplied base URL guard applies in every environment', () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllEnvs();
clearImageEnv();
delete process.env.ALLOW_LOCAL_NETWORKS;
mocks.generateImage.mockReset();
mocks.generateImage.mockResolvedValue({ url: 'https://example.com/img.png' });
});
it('rejects a metadata-address base URL when NODE_ENV is not production', async () => {
vi.stubEnv('NODE_ENV', 'development');
const { POST } = await import('@/app/api/generate/image/route');
const res = await POST(
imageRequest({
'x-image-provider': 'openai-image',
'x-api-key': 'client-key',
'x-image-model': 'gpt-image-2',
'x-base-url': 'http://169.254.169.254/latest/meta-data/',
}),
);
const json = await res.json();
expect(res.status).toBe(403);
expect(json).toMatchObject({ success: false, errorCode: 'INVALID_URL' });
expect(mocks.generateImage).not.toHaveBeenCalled();
});
it('still allows the same local base URL when ALLOW_LOCAL_NETWORKS=true', async () => {
vi.stubEnv('NODE_ENV', 'development');
vi.stubEnv('ALLOW_LOCAL_NETWORKS', 'true');
const { POST } = await import('@/app/api/generate/image/route');
const res = await POST(
imageRequest({
'x-image-provider': 'openai-image',
'x-api-key': 'client-key',
'x-image-model': 'gpt-image-2',
'x-base-url': 'http://169.254.169.254/latest/meta-data/',
}),
);
expect(res.status).toBe(200);
expect(mocks.generateImage).toHaveBeenCalledWith(
expect.objectContaining({
providerId: 'openai-image',
model: 'gpt-image-2',
baseUrl: 'http://169.254.169.254/latest/meta-data/',
}),
expect.anything(),
);
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* End-to-end proof for the affected outbound path: a chat-completion model
* built the way resolveModel builds one (openai-compatible, client-supplied
* base URL, redirect-validating fetch installed) must re-validate every
* redirect hop the origin answers with. DNS lookups are stubbed like the
* ssrf-guard tests; the fetch transport is a stub recording every URL it was
* asked to open.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { generateText } from 'ai';
const { lookupMock } = vi.hoisted(() => ({
lookupMock: vi.fn(),
}));
vi.mock('node:dns', () => ({
promises: {
lookup: lookupMock,
},
}));
import { fetchWithRedirectValidation } from '@/lib/server/fetch-with-redirect-validation';
import { getModel } from '@/lib/ai/providers';
function chatCompletionBody(text: string): string {
return JSON.stringify({
id: 'chatcmpl-redirect-test',
object: 'chat.completion',
created: 1,
model: 'gpt-4o-mini',
choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
});
}
describe('chat-completion model fetch re-validates redirect hops', () => {
beforeEach(() => {
vi.resetModules();
lookupMock.mockReset();
delete process.env.ALLOW_LOCAL_NETWORKS;
lookupMock.mockImplementation(async (hostname: string) => {
if (hostname === 'cdn.public.example') {
return [{ address: '93.184.216.34', family: 4 }];
}
throw new Error(`ENOTFOUND ${hostname}`);
});
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.ALLOW_LOCAL_NETWORKS;
});
it('never lets a 302 to a loopback address be followed during generation', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: 'http://127.0.0.1:8080/steal-key' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const { model } = getModel({
providerId: 'openai',
modelId: 'gpt-4o-mini',
apiKey: 'sk-test',
baseUrl: 'https://api.public.example/v1',
fetchImpl: fetchWithRedirectValidation,
});
await expect(
generateText({
model,
prompt: 'hi',
maxRetries: 0,
}),
).rejects.toThrow();
const requestedUrls = fetchMock.mock.calls.map(([input]) => String(input));
expect(requestedUrls).toEqual(['https://api.public.example/v1/chat/completions']);
expect(requestedUrls.join(' ')).not.toContain('127.0.0.1');
// The redirect-validating transport requested manual redirect handling;
// nothing followed the 302 on its own.
expect((fetchMock.mock.calls[0][1] as RequestInit).redirect).toBe('manual');
});
it('follows a 302 to another public address and completes generation against it', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(null, {
status: 302,
headers: { location: 'https://cdn.public.example/v1/chat/completions' },
}),
)
.mockResolvedValueOnce(
new Response(chatCompletionBody('redirected ok'), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const { model } = getModel({
providerId: 'openai',
modelId: 'gpt-4o-mini',
apiKey: 'sk-test',
baseUrl: 'https://api.public.example/v1',
fetchImpl: fetchWithRedirectValidation,
});
const result = await generateText({
model,
prompt: 'hi',
maxRetries: 0,
});
expect(result.text).toBe('redirected ok');
expect(String(fetchMock.mock.calls[1][0])).toBe(
'https://cdn.public.example/v1/chat/completions',
);
for (const [, init] of fetchMock.mock.calls) {
expect((init as RequestInit).redirect).toBe('manual');
}
});
});
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// resolveModel is the single server-side funnel that builds LLM models for the
// BYOK chat / verify-model paths. It must install the redirect-validating
// transport on every model it resolves — a client-supplied base URL cannot be
// the only protected hop, because the redirect target is not known until the
// origin answers.
const mocks = vi.hoisted(() => ({
getModelCalls: [] as Array<Record<string, unknown>>,
serverManaged: false,
}));
vi.mock('@/lib/ai/providers', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/ai/providers')>();
return {
...actual,
getModel: (args: Record<string, unknown>) => {
mocks.getModelCalls.push(args);
return { model: { id: args.modelId }, modelInfo: undefined };
},
};
});
vi.mock('@/lib/server/provider-config', () => ({
isServerConfiguredProvider: () => mocks.serverManaged,
resolveApiKey: (_id: string, clientKey: string) => clientKey || 'server-key',
resolveBaseUrl: (_id: string, clientBaseUrl?: string) => clientBaseUrl,
resolveProxy: () => undefined,
}));
describe('resolveModel — installs the redirect-validating transport on every model', () => {
beforeEach(() => {
vi.resetModules();
delete process.env.ALLOW_LOCAL_NETWORKS;
delete process.env.MODEL_ROUTES;
delete process.env.DEFAULT_MODEL;
mocks.getModelCalls.length = 0;
mocks.serverManaged = false;
});
it('passes fetchWithRedirectValidation as the fetch implementation for a client-supplied base URL', async () => {
const { resolveModel } = await import('@/lib/server/resolve-model');
const { fetchWithRedirectValidation } =
await import('@/lib/server/fetch-with-redirect-validation');
await resolveModel({
modelString: 'openai:gpt-5.4-mini',
apiKey: 'client-key',
baseUrl: 'https://8.8.8.8/v1',
});
expect(mocks.getModelCalls.at(-1)).toMatchObject({
baseUrl: 'https://8.8.8.8/v1',
fetchImpl: fetchWithRedirectValidation,
});
});
it('keeps the same hop re-validation for managed providers, whose origin is operator-trusted but whose redirects are not', async () => {
mocks.serverManaged = true;
const { resolveModel } = await import('@/lib/server/resolve-model');
const { fetchWithRedirectValidation } =
await import('@/lib/server/fetch-with-redirect-validation');
await resolveModel({
modelString: 'openai:gpt-5.4-mini',
apiKey: 'server-key',
});
const call = mocks.getModelCalls.at(-1)!;
expect(call.baseUrl).toBeUndefined();
expect(call.fetchImpl).toBe(fetchWithRedirectValidation);
});
});
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// resolveModel throws when a client-supplied base URL is unsafe, in every
// environment — the ALLOW_LOCAL_NETWORKS escape hatch stays honored by the
// guard itself. This file keeps the real ssrf-guard (no mock) so the private
// address classification is exercised end to end.
const mocks = vi.hoisted(() => ({
getModelCalls: [] as Array<Record<string, unknown>>,
serverManaged: false,
}));
vi.mock('@/lib/ai/providers', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/ai/providers')>();
return {
...actual,
getModel: (args: Record<string, unknown>) => {
mocks.getModelCalls.push(args);
return { model: { id: args.modelId }, modelInfo: undefined };
},
};
});
vi.mock('@/lib/server/provider-config', () => ({
isServerConfiguredProvider: () => mocks.serverManaged,
resolveApiKey: (_id: string, clientKey: string) => clientKey || 'server-key',
resolveBaseUrl: (_id: string, clientBaseUrl?: string) => clientBaseUrl,
resolveProxy: () => undefined,
}));
describe('resolveModel — client-supplied base URL guard applies in every environment', () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllEnvs();
delete process.env.ALLOW_LOCAL_NETWORKS;
delete process.env.MODEL_ROUTES;
delete process.env.DEFAULT_MODEL;
mocks.getModelCalls.length = 0;
mocks.serverManaged = false;
});
it('rejects a metadata-address base URL when NODE_ENV is not production', async () => {
vi.stubEnv('NODE_ENV', 'development');
const { resolveModel } = await import('@/lib/server/resolve-model');
await expect(
resolveModel({
modelString: 'openai:gpt-5.4-mini',
apiKey: 'client-key',
baseUrl: 'http://169.254.169.254/latest/meta-data/',
}),
).rejects.toThrow(/Local\/private network URLs are not allowed/);
expect(mocks.getModelCalls).toHaveLength(0);
});
it('still allows the same local base URL when ALLOW_LOCAL_NETWORKS=true', async () => {
vi.stubEnv('NODE_ENV', 'development');
vi.stubEnv('ALLOW_LOCAL_NETWORKS', 'true');
const { resolveModel } = await import('@/lib/server/resolve-model');
const result = await resolveModel({
modelString: 'openai:gpt-5.4-mini',
apiKey: 'client-key',
baseUrl: 'http://169.254.169.254/latest/meta-data/',
});
expect(result.modelId).toBe('gpt-5.4-mini');
expect(mocks.getModelCalls.at(-1)).toMatchObject({
baseUrl: 'http://169.254.169.254/latest/meta-data/',
});
});
});
+553
View File
@@ -0,0 +1,553 @@
// @vitest-environment jsdom
/**
* Scene-content sanitizer invariants.
*
* INVARIANT: stored slide HTML is restricted to the formatting vocabulary the
* renderer produces. These tests prove (a) that realistic slide formatting —
* drawn from real stage/scene fixtures and the editor round-trip corpus in this
* repo — survives sanitization unchanged at the DOM level, and (b) that markup
* outside that vocabulary (event handlers, script elements, javascript: URLs,
* embedded frames) does not survive.
*/
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import katex from 'katex';
import {
sanitizeLatexHtml,
sanitizeProseHtml,
sanitizeSceneContent,
} from '@/lib/server/sanitize-scene-content';
// ---------------------------------------------------------------------------
// DOM-level canonicalization: proves the sanitized fragment renders the same
// tree as the input, ignoring only cosmetic attribute serialization
// differences (style whitespace, attribute order).
// ---------------------------------------------------------------------------
function styleDeclarations(style: string): Array<[string, string]> {
return style
.split(';')
.map((decl) => decl.trim())
.filter(Boolean)
.map((decl): [string, string] => {
const colon = decl.indexOf(':');
if (colon === -1) return [decl.toLowerCase(), ''];
return [decl.slice(0, colon).trim().toLowerCase(), decl.slice(colon + 1).trim()];
})
.sort((a, b) => a[0].localeCompare(b[0]));
}
function canonNode(node: ChildNode): unknown {
if (node.nodeType === Node.TEXT_NODE) return ['text', node.textContent ?? ''];
if (node.nodeType !== Node.ELEMENT_NODE) return ['node', String(node.nodeType)];
const element = node as Element;
const attrs: unknown[] = [];
for (const attribute of Array.from(element.attributes).sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
)) {
const name = attribute.name.toLowerCase();
const value = attribute.value;
if (name === 'style') {
attrs.push(['style', styleDeclarations(value)]);
} else if (name === 'class') {
attrs.push(['class', value.split(/\s+/).filter(Boolean)]);
} else {
attrs.push([name, value]);
}
}
const children = Array.from(element.childNodes).map(canonNode);
return ['element', element.tagName.toLowerCase(), attrs, children];
}
function canon(html: string): unknown {
const template = document.createElement('template');
template.innerHTML = html;
return Array.from(template.content.childNodes).map(canonNode);
}
function expectProseUnchanged(html: string): void {
expect(canon(sanitizeProseHtml(html)), html).toEqual(canon(html));
}
// ---------------------------------------------------------------------------
// Real repo fixture content
// ---------------------------------------------------------------------------
/** Read real stored scene JSON fixtures and collect every HTML-bearing string. */
function fixtureHtmlStrings(...jsonPaths: string[]): string[] {
const collected: string[] = [];
const walk = (value: unknown): void => {
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (typeof value !== 'object' || value === null) return;
const record = value as Record<string, unknown>;
if (record.type === 'text' && typeof record.content === 'string') {
collected.push(record.content);
} else if (
record.type === 'shape' &&
typeof record.text === 'object' &&
record.text !== null &&
typeof (record.text as Record<string, unknown>).content === 'string'
) {
collected.push((record.text as Record<string, unknown>).content as string);
} else if (record.type === 'table' && Array.isArray(record.data)) {
const text: string[] = [];
const rows = record.data as unknown[][];
rows.forEach((row) =>
row.forEach((cell) => {
if (
typeof cell === 'object' &&
cell !== null &&
typeof (cell as Record<string, unknown>).text === 'string'
) {
text.push((cell as Record<string, unknown>).text as string);
}
}),
);
collected.push(...text);
} else if (record.type === 'latex' && typeof record.html === 'string') {
collected.push(record.html);
}
Object.values(record).forEach(walk);
};
for (const jsonPath of jsonPaths) {
const parsed = JSON.parse(readFileSync(new URL(jsonPath, import.meta.url), 'utf-8')) as unknown;
walk(parsed);
}
return collected;
}
const evalSceneFixtures = [
'../../eval/whiteboard-layout/scenarios/econ-tech-innovation.json',
'../../eval/whiteboard-layout/scenarios/math-quadratic-inequality.json',
'../../eval/whiteboard-layout/scenarios/med-gcp-compliance.json',
'../../eval/whiteboard-layout/scenarios/finance-tax-architecture.json',
'../../eval/whiteboard-layout/scenarios/primary-math-rotation.json',
'../../eval/whiteboard-layout/scenarios/physics-force-decomposition.json',
'../../eval/orchestration/scenarios/answer-content.json',
];
// ---------------------------------------------------------------------------
// 1. Removing what must not survive
// ---------------------------------------------------------------------------
describe('sanitizeProseHtml — content outside the renderer vocabulary', () => {
it('drops an inline event handler while preserving surrounding markup', () => {
const input =
'<p>Keep <strong>bold</strong> and <em>italic</em></p><p><img src="x" onerror="alert(1)">tail</p>';
const output = sanitizeProseHtml(input);
expect(output).not.toContain('onerror');
expect(output).not.toContain('<img');
expect(canon(output)).toEqual(
canon('<p>Keep <strong>bold</strong> and <em>italic</em></p><p>tail</p>'),
);
});
it('drops script elements including their body', () => {
const output = sanitizeProseHtml('<p>a</p><script>alert(1)</script><p>b</p>');
expect(output).not.toContain('<script');
expect(output).not.toContain('alert(1)');
expect(canon(output)).toEqual(canon('<p>a</p><p>b</p>'));
});
it('drops every on* attribute on any tag', () => {
const output = sanitizeProseHtml(
'<p onclick="x()" onmouseover="y()">t</p><span ondblclick="z()">s</span>',
);
expect(output).not.toMatch(/\son\w+=/);
expect(canon(output)).toEqual(canon('<p>t</p><span>s</span>'));
});
it('drops javascript: URLs from links but keeps the link text', () => {
const output = sanitizeProseHtml('<p><a href="javascript:alert(1)">click</a></p>');
expect(output).not.toContain('javascript:');
expect(canon(output)).toEqual(canon('<p><a>click</a></p>'));
});
it('drops iframe, object and embed from element content', () => {
const output = sanitizeProseHtml(
'<p>ok</p><iframe src="https://example.com"></iframe><object data="x"></object><embed src="y">',
);
expect(output).not.toContain('<iframe');
expect(output).not.toContain('<object');
expect(output).not.toContain('<embed');
expect(canon(output)).toEqual(canon('<p>ok</p>'));
});
it('never allows style properties outside the typography vocabulary', () => {
const output = sanitizeProseHtml(
'<p style="color:red; background:url(https://evil.test/x); position:fixed; font-size:14px">t</p>',
);
expect(output).not.toContain('background:');
expect(output).not.toContain('url(');
expect(output).not.toContain('position:');
expect(canon(output)).toEqual(canon('<p style="color:red;font-size:14px">t</p>'));
});
});
describe('sanitizeLatexHtml — KaTeX snapshot policy', () => {
it('removes handlers, scripts and foreign tags from a KaTeX snapshot', () => {
const input =
'<span class="katex">E</span><img src=x onerror="alert(1)"><script>alert(2)</script>';
const output = sanitizeLatexHtml(input);
expect(output).not.toContain('onerror');
expect(output).not.toContain('<img');
expect(output).not.toContain('<script');
expect(canon(output)).toEqual(canon('<span class="katex">E</span>'));
});
it('keeps real KaTeX HTML (spans + layout svg) semantically unchanged', () => {
const formulas = [
'E = mc^2',
'\\frac{a}{b} + \\sqrt{x^2 + y^2}',
'H_2O \\quad \\text{water}',
'\\sum_{i=1}^{n} i^2',
'\\begin{cases} 1 & x>0 \\\\ 0 & x\\le 0 \\end{cases}',
'\\cancel{5} + \\overrightarrow{AB}',
];
for (const formula of formulas) {
const html = katex.renderToString(formula, {
throwOnError: false,
displayMode: true,
output: 'html',
});
expect(canon(sanitizeLatexHtml(html)), formula).toEqual(canon(html));
}
});
it('preserves the camelCase layout-svg attributes browsers need', () => {
const html = katex.renderToString('\\sqrt{x}', {
throwOnError: false,
displayMode: true,
output: 'html',
});
const output = sanitizeLatexHtml(html);
if (html.includes('viewBox=')) {
expect(output).toContain('viewBox=');
}
if (html.includes('preserveAspectRatio=')) {
expect(output).toContain('preserveAspectRatio=');
}
});
});
// ---------------------------------------------------------------------------
// 2. Fidelity: realistic slide formatting survives unchanged
// ---------------------------------------------------------------------------
describe('sanitizeProseHtml — fidelity over the editor round-trip corpus', () => {
it('keeps nested marks, links and alignment (editor round-trip fixture)', () => {
// Verbatim from packages/@openmaic/editor/test/react/text/prosemirror-schema.test.ts.
const html =
'<blockquote><p style="text-align: center"><a href="https://maic.chat"><strong><u><span style="font-size: 28px; color: #ff0000">MAIC</span></u></strong></a></p></blockquote><ol><li><p>One</p></li></ol>';
expectProseUnchanged(html);
});
it('keeps lists in the exact shape the renderer emits them', () => {
// Verbatim from packages/@openmaic/renderer/test/SlideCanvas.test.tsx.
expectProseUnchanged('<ul><li>Bullet</li></ul><ol><li>Number</li></ol>');
expectProseUnchanged(
'<ul style="list-style-type: disc"><li><p>A</p></li></ul><ol start="3"><li><p>B</p></li></ol>',
);
});
it('keeps paragraph geometry imported from PPTX (round-trip fixture)', () => {
// Verbatim from packages/@openmaic/editor/test/react/text/prosemirror-schema.test.ts.
expectProseUnchanged(
'<div style="padding: 4.8px 9.6px"><p style="margin-left: 78px; text-indent: -30px; padding-top: 7.3px; margin-top: 8px; margin-bottom: 5px">Text</p></div>',
);
});
it('keeps bullet-glyph inline-block spans (round-trip fixture)', () => {
// Verbatim from packages/@openmaic/editor/test/react/text/prosemirror-schema.test.ts.
expectProseUnchanged(
'<p><span style="display: inline-block; width: 30px; text-indent: 0; box-sizing: border-box">■</span>1954年清华大学首创</p>',
);
expectProseUnchanged(
'<p><span style="display: inline-block; width: 30px; height: 24px; vertical-align: middle; margin: 1px 2px; padding: 3px 4px">■</span><span style="display: inline-block; width: 12px; margin-left: 5px; padding-right: 6px">•</span>Text</p>',
);
});
it('keeps explicit line breaks and run-level spans (round-trip fixture)', () => {
// Verbatim from packages/@openmaic/editor/test/react/text/prosemirror-schema.test.ts.
expectProseUnchanged(
'<p><span style="font-size: 29.3px">1954年清华大学首创“先进集体”</span><br><span style="font-size: 29.3px">评选制度</span></p>',
);
});
it('keeps character spacing, indentation and nowrap (round-trip fixtures)', () => {
// Verbatim from packages/@openmaic/editor/test/react/text/prosemirror-schema.test.ts.
expectProseUnchanged(
'<p style="text-indent: 78px"><span style="letter-spacing: 1.5pt">Indented text</span></p>',
);
expectProseUnchanged('<p style="white-space: nowrap">在集体中成长,与集体共成长</p>');
expectProseUnchanged('<p style="font-size: 14px; line-height: 1.2">Text</p>');
});
it('keeps inline marks: code, sub, sup, mark, strike variants, underline', () => {
expectProseUnchanged(
'text <code>const x = 1</code> <sub>sub</sub> <sup>sup</sup> <mark data-index="1">hi</mark> <s>del</s> <strike>old</strike> <u>under</u> <b>b</b> <i>i</i>',
);
});
it('keeps structural table markup when present in a stored body', () => {
expectProseUnchanged(
'<table><thead><tr><th>Header</th></tr></thead><tbody><tr><td>Cell <sup>2</sup></td><td colspan="2">Wide</td></tr></tbody></table>',
);
});
});
describe('real stored scene fixtures — full payload walker fidelity', () => {
it('leaves every HTML-bearing string of real scene fixtures unchanged', () => {
const strings = fixtureHtmlStrings(...evalSceneFixtures);
expect(strings.length).toBeGreaterThan(20);
for (const html of strings) {
expectProseUnchanged(html);
}
});
it('leaves real text-element content unchanged when sanitizing a whole scene', () => {
// Real scene structure: eval/orchestration/scenarios/answer-content.json,
// scene 0 text element "content".
const scene = {
id: 'scene-0',
stageId: 'eval-answer-content',
title: '二次函数',
order: 0,
type: 'slide',
content: {
type: 'slide',
canvas: {
id: 'slide-0',
viewportSize: 1000,
viewportRatio: 0.5625,
theme: {
backgroundColor: '#ffffff',
themeColors: ['#5b9bd5'],
fontColor: '#333333',
fontName: 'Microsoft YaHei',
},
elements: [
{
type: 'text',
id: 'title-el',
content: '<p style="font-size: 32px;"><b>二次函数 y = ax² + bx + c</b></p>',
left: 50,
top: 50,
width: 900,
height: 100,
rotate: 0,
defaultFontName: 'Microsoft YaHei',
defaultColor: '#333333',
},
],
},
},
};
const sanitized = sanitizeSceneContent(scene);
const element = sanitized.content.canvas.elements[0];
expect(canon(element.content)).toEqual(
canon('<p style="font-size: 32px;"><b>二次函数 y = ax² + bx + c</b></p>'),
);
expect(sanitized).not.toBe(scene);
});
});
// ---------------------------------------------------------------------------
// 3. Payload walker: element-kind coverage and untouched non-HTML fields
// ---------------------------------------------------------------------------
function slideSceneWithElements(elements: unknown[]): unknown {
return {
id: 'scene-1',
stageId: 'stage-1',
title: 'Scene',
order: 0,
type: 'slide',
content: {
type: 'slide',
canvas: {
id: 'slide-1',
viewportSize: 1000,
viewportRatio: 0.5625,
theme: {
backgroundColor: '#ffffff',
themeColors: ['#5b9bd5'],
fontColor: '#333333',
fontName: 'Microsoft YaHei',
},
elements,
},
},
};
}
describe('sanitizeSceneContent — payload walker', () => {
it('sanitizes text, shape text and table cell text and latex snapshots in one pass', () => {
const scene = slideSceneWithElements([
{
type: 'text',
id: 't1',
content: '<p><img src=x onerror="a()">keep <strong>bold</strong></p>',
left: 0,
top: 0,
width: 100,
height: 40,
rotate: 0,
defaultFontName: 'Arial',
defaultColor: '#333',
},
{
type: 'shape',
id: 's1',
left: 0,
top: 0,
width: 100,
height: 100,
rotate: 0,
viewBox: [100, 100],
path: 'M0 0',
fixedRatio: true,
fill: '#fff',
text: {
content: '<p onclick="x()"><em>shape text</em></p>',
defaultFontName: 'Arial',
defaultColor: '#333',
align: 'middle',
},
},
{
type: 'table',
id: 'tbl1',
left: 0,
top: 0,
width: 100,
height: 60,
rotate: 0,
outline: { width: 1, color: '#000', style: 'solid' },
colWidths: [1],
cellMinHeight: 30,
data: [
[
{
id: 'c1',
colspan: 1,
rowspan: 1,
text: '<strong>H</strong><sub>2</sub>O<script>a()</script>',
},
],
],
},
{
type: 'latex',
id: 'l1',
left: 0,
top: 0,
width: 200,
height: 60,
rotate: 0,
latex: 'x',
html: '<span class="katex">x</span><svg onload="a()"><line x1="0" y1="0" x2="1" y2="1" stroke-width="0.04em"/></svg>',
color: '#000',
fixedRatio: true,
},
]);
const sanitized = sanitizeSceneContent(scene) as {
content: { canvas: { elements: Array<Record<string, unknown>> } };
};
const [textEl, shapeEl, tableEl, latexEl] = sanitized.content.canvas.elements;
expect(String(textEl.content)).not.toContain('onerror');
expect(String(textEl.content)).not.toContain('<img');
expect(canon(String(textEl.content))).toEqual(canon('<p>keep <strong>bold</strong></p>'));
const shapeText = shapeEl.text as Record<string, unknown>;
expect(String(shapeText.content)).not.toContain('onclick');
expect(canon(String(shapeText.content))).toEqual(canon('<p><em>shape text</em></p>'));
const cellText = (tableEl.data as Array<Array<Record<string, unknown>>>)[0][0].text as string;
expect(cellText).not.toContain('<script');
expect(canon(cellText)).toEqual(canon('<strong>H</strong><sub>2</sub>O'));
const latexHtml = latexEl.html as string;
expect(latexHtml).not.toContain('onload');
expect(latexHtml).not.toContain('<svg onload');
expect(canon(latexHtml)).toEqual(
canon(
'<span class="katex">x</span><svg><line x1="0" y1="0" x2="1" y2="1" stroke-width="0.04em"/></svg>',
),
);
});
it('does not touch code element lines (plain text, not HTML)', () => {
const code = 'int a = 1 < 2 && 3 > 2; // a < b "quoted"';
const scene = slideSceneWithElements([
{
type: 'code',
id: 'code-1',
left: 0,
top: 0,
width: 100,
height: 100,
rotate: 0,
language: 'cpp',
lines: [{ id: 'L1', content: code }],
showLineNumbers: true,
fontSize: 14,
},
]);
const sanitized = sanitizeSceneContent(scene) as {
content: { canvas: { elements: Array<{ lines: Array<{ content: string }> }> } };
};
expect(sanitized.content.canvas.elements[0].lines[0].content).toBe(code);
});
it('returns a new tree and leaves the caller payload untouched', () => {
const scene = slideSceneWithElements([
{
type: 'text',
id: 't1',
content: '<p onclick="x()">hello</p>',
left: 0,
top: 0,
width: 100,
height: 40,
rotate: 0,
defaultFontName: 'Arial',
defaultColor: '#333',
},
]);
const before = JSON.stringify(scene);
const sanitized = sanitizeSceneContent(scene);
expect(JSON.stringify(scene)).toBe(before);
expect(sanitized).not.toBe(scene);
});
it('is idempotent: sanitizing an already-sanitized payload changes nothing', () => {
const scene = slideSceneWithElements([
{
type: 'text',
id: 't1',
content: '<p style="color:#ff0000; font-size:14px"><b>bold</b></p>',
left: 0,
top: 0,
width: 100,
height: 40,
rotate: 0,
defaultFontName: 'Arial',
defaultColor: '#333',
},
]);
const once = sanitizeSceneContent(scene);
const twice = sanitizeSceneContent(once);
expect(twice).toEqual(once);
});
});
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// A client-supplied ASR base URL must be validated in every environment, not
// only when NODE_ENV === 'production'. The self-hosting escape hatch is
// ALLOW_LOCAL_NETWORKS, which the guard itself honors. This file keeps the
// real ssrf-guard (no mock) so the private-address classification is exercised
// end to end.
const mocks = vi.hoisted(() => ({
transcribeAudio: vi.fn(),
serverManaged: false,
serverDisabled: false,
}));
vi.mock('@/lib/audio/asr-providers', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/audio/asr-providers')>();
return {
...actual,
transcribeAudio: mocks.transcribeAudio,
};
});
vi.mock('@/lib/server/provider-config', () => ({
isServerConfiguredProvider: () => mocks.serverManaged,
isServerProviderDisabled: () => mocks.serverDisabled,
resolveASRApiKey: (_id: string, clientKey?: string | null) => clientKey || 'server-key',
resolveASRBaseUrl: (_id: string, clientBaseUrl?: string | null) => clientBaseUrl || undefined,
resolveASRModel: (_id: string, clientModel?: string | null) => clientModel || 'whisper-1',
resolveServerASRProviderId: () => undefined,
}));
vi.mock('@/lib/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
async function postTranscription(baseUrl: string) {
const { POST } = await import('@/app/api/transcription/route');
const formData = new FormData();
formData.append('audio', new File([new Uint8Array([1, 2, 3])], 'clip.mp3'), 'clip.mp3');
formData.append('providerId', 'openai');
formData.append('modelId', 'whisper-1');
formData.append('language', 'en');
formData.append('apiKey', 'client-key');
formData.append('baseUrl', baseUrl);
const req = new NextRequest('http://localhost/api/transcription', {
method: 'POST',
body: formData,
});
return POST(req);
}
describe('transcription — client-supplied base URL guard applies in every environment', () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllEnvs();
delete process.env.ALLOW_LOCAL_NETWORKS;
mocks.transcribeAudio.mockReset();
mocks.transcribeAudio.mockResolvedValue({ text: 'hello' });
mocks.serverManaged = false;
mocks.serverDisabled = false;
});
it('rejects a metadata-address base URL when NODE_ENV is not production', async () => {
vi.stubEnv('NODE_ENV', 'development');
const res = await postTranscription('http://169.254.169.254/latest/meta-data/');
const json = await res.json();
expect(res.status).toBe(403);
expect(json).toMatchObject({ success: false, errorCode: 'INVALID_URL' });
expect(mocks.transcribeAudio).not.toHaveBeenCalled();
});
it('still lets the same local base URL through when ALLOW_LOCAL_NETWORKS=true', async () => {
vi.stubEnv('NODE_ENV', 'development');
vi.stubEnv('ALLOW_LOCAL_NETWORKS', 'true');
const res = await postTranscription('http://169.254.169.254/latest/meta-data/');
const json = await res.json();
expect(res.status).toBe(200);
expect(json).toMatchObject({ success: true });
expect(mocks.transcribeAudio).toHaveBeenCalledWith(
expect.objectContaining({
providerId: 'openai',
baseUrl: 'http://169.254.169.254/latest/meta-data/',
}),
expect.any(File),
);
});
});
@@ -0,0 +1,260 @@
/**
* Repo-wide invariant: `validateUrlForSSRF` runs at every call site in every
* environment.
*
* The guard itself owns the documented self-hosting escape hatch
* (`ALLOW_LOCAL_NETWORKS`), so a route-level `process.env.NODE_ENV` condition
* around a call is always redundant: outside a production build it silently
* disables the check. That gate was once applied at several API routes from a
* hand-written list that drifted from the code. This test makes the gap
* un-reintroducible: it walks the repository source, finds every
* `validateUrlForSSRF` call site, and fails if any call site sits inside an
* `if` block whose condition references `NODE_ENV`. The failure names the
* offending file and line so the reintroduction is caught at review time, not
* in production.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { describe, expect, it } from 'vitest';
const ROOT = process.cwd();
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']);
/** Directories that are not repository source (deps, build output, fixtures). */
const SKIPPED_DIRECTORIES = new Set([
'.git',
'.next',
'.codegraph',
'.turbo',
'.vercel',
'.cache',
'coverage',
'dist',
'build',
'node_modules',
'data',
'public',
'assets',
]);
/**
* Return every call occurrence as a 1-based line number. The definition in
* ssrf-guard.ts matches too and is fine: it is never gated.
*/
function findCallLines(content: string): number[] {
const lines: number[] = [];
const callPattern = /validateUrlForSSRF\s*\(/g;
let match: RegExpExecArray | null;
while ((match = callPattern.exec(content)) !== null) {
let line = 1;
for (let i = 0; i < match.index; i++) {
if (content[i] === '\n') line += 1;
}
lines.push(line);
}
return lines;
}
/**
* Replace string/comment/regex-literal bodies with spaces (keeping length and
* newlines) so brace and parenthesis matching only ever sees structure.
*/
function blankLiterals(source: string): string {
let out = '';
let i = 0;
while (i < source.length) {
const ch = source[i];
const next = source[i + 1];
if (ch === '/' && next === '/') {
while (i < source.length && source[i] !== '\n') {
out += ' ';
i += 1;
}
continue;
}
if (ch === '/' && next === '*') {
out += ' ';
i += 2;
while (i < source.length) {
if (source[i] === '*' && source[i + 1] === '/') {
out += ' ';
i += 2;
break;
}
out += source[i] === '\n' ? '\n' : ' ';
i += 1;
}
continue;
}
if (ch === "'" || ch === '"' || ch === '`') {
const quote = ch;
out += ' ';
i += 1;
while (i < source.length) {
if (source[i] === '\\') {
out += ' ';
i += 2;
continue;
}
if (source[i] === quote) {
out += ' ';
i += 1;
break;
}
out += source[i] === '\n' ? '\n' : ' ';
i += 1;
}
continue;
}
if (ch === '/' && /[^/]/.test(next ?? '')) {
// Heuristic regex literal: skip to the next unescaped `/` on the same
// line. Regexes cannot span lines without flags, so this is safe enough
// for structural scanning.
out += ' ';
i += 1;
while (i < source.length && source[i] !== '\n') {
if (source[i] === '\\') {
out += ' ';
i += 2;
continue;
}
if (source[i] === '/') {
out += ' ';
i += 1;
break;
}
out += ' ';
i += 1;
}
continue;
}
out += ch;
i += 1;
}
return out;
}
/** Walk from an opening `(` to its matching `)` and return the end index. */
function matchingParen(text: string, openIndex: number): number {
let depth = 0;
for (let i = openIndex; i < text.length; i++) {
if (text[i] === '(') depth += 1;
else if (text[i] === ')') {
depth -= 1;
if (depth === 0) return i;
}
}
return -1;
}
/**
* Find every `if (...)` block whose condition references `NODE_ENV` and return
* the 1-based line ranges of their braced bodies. Mirrors the historical gate
* shape (`if (x && process.env.NODE_ENV === 'production') { ... }`) as well as
* inverted `!== 'production'` variants and single-statement bodies.
*/
function findNodeEnvGuardedBodyLines(source: string): Set<number> {
const guarded = new Set<number>();
const text = blankLiterals(source);
const ifPattern = /\bif\s*\(/g;
let match: RegExpExecArray | null;
while ((match = ifPattern.exec(text)) !== null) {
const openParen = text.indexOf('(', match.index);
const closeParen = matchingParen(text, openParen);
if (closeParen === -1) continue;
// Read the condition from the ORIGINAL text so `NODE_ENV` is recognized in
// every spelling (dot or bracket access), not only where it is not inside
// a literal.
const condition = source.slice(match.index, closeParen + 1);
if (!/\bNODE_ENV\b/.test(condition)) continue;
// Skip whitespace and any comment residue (already blanked) to the body.
let body = closeParen + 1;
while (body < text.length && /\s/.test(text[body])) body += 1;
const bodyStart = body;
let bodyEnd = -1;
if (text[body] === '{') {
let depth = 0;
for (let i = body; i < text.length; i++) {
if (text[i] === '{') depth += 1;
else if (text[i] === '}') {
depth -= 1;
if (depth === 0) {
bodyEnd = i;
break;
}
}
}
} else {
// Single-statement body: ends at the first `;` on this construct.
const semicolon = text.indexOf(';', body);
bodyEnd = semicolon === -1 ? text.length - 1 : semicolon;
}
if (bodyEnd === -1) continue;
const firstLine = lineOfIndex(text, bodyStart);
const lastLine = lineOfIndex(text, bodyEnd);
for (let line = firstLine; line <= lastLine; line++) guarded.add(line);
}
return guarded;
}
/** Map a character index back to its 1-based line number. */
function lineOfIndex(content: string, index: number): number {
let line = 1;
const end = Math.min(index, content.length - 1);
for (let i = 0; i < end; i++) {
if (content[i] === '\n') line += 1;
}
return line;
}
function collectSourceFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
let stats: ReturnType<typeof statSync>;
try {
stats = statSync(full);
} catch {
continue;
}
if (stats.isDirectory()) {
if (!SKIPPED_DIRECTORIES.has(entry)) files.push(...collectSourceFiles(full));
} else if (SOURCE_EXTENSIONS.has(entry.slice(entry.lastIndexOf('.')))) {
files.push(full);
}
}
return files;
}
describe('validateUrlForSSRF call sites are never gated by NODE_ENV', () => {
it('finds no call site inside a NODE_ENV-conditional block in the repository source', () => {
const offenders: string[] = [];
const scanned: string[] = [];
let callSites = 0;
for (const file of collectSourceFiles(ROOT)) {
const rel = relative(ROOT, file);
if (rel === 'tests/server/url-guard-unconditional-invariant.test.ts') continue;
scanned.push(rel);
const content = readFileSync(file, 'utf-8');
const callLines = findCallLines(content);
if (callLines.length === 0) continue;
callSites += callLines.length;
const guardedLines = findNodeEnvGuardedBodyLines(content);
for (const callLine of callLines) {
if (guardedLines.has(callLine)) {
offenders.push(`${rel}:${callLine}`);
}
}
}
expect(scanned.length).toBeGreaterThan(500);
expect(callSites).toBeGreaterThanOrEqual(30);
expect(offenders).toEqual([]);
});
});