mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-02 09:24:43 +08:00
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:
@@ -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=
|
||||
|
||||
@@ -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'}]:`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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/',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user