fix(config): workspace image and video providers run public-only

A workspace-configured image or video provider now uses the strict public
transport for its requests and for the redirects its clip download follows,
whatever ALLOW_LOCAL_NETWORKS says; the operator's own providers and the
deprecated per-request path keep their policies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR
This commit is contained in:
wyuc
2026-09-29 12:45:35 -04:00
co-authored by Claude Opus 5.5
parent f8a5b45fc5
commit 68021dedae
7 changed files with 118 additions and 32 deletions
+6 -3
View File
@@ -35,7 +35,7 @@ import type { ImageProviderId, ImageGenerationOptions } from '@/lib/media/types'
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { validateClientBaseUrl } from '@/lib/server/ssrf-guard';
import { withMediaProviderFetch } from '@/lib/server/media-provider-fetch';
import { mediaNetworkPolicy, withMediaProviderFetch } from '@/lib/server/media-provider-fetch';
import { resolveImageSize } from '@/lib/server/image-sizing';
const log = createLogger('ImageGeneration API');
@@ -68,7 +68,7 @@ export async function POST(request: NextRequest) {
if (refused) return refused;
throw error;
}
const { providerId, apiKey, baseUrl, managed } = connection as MediaConnection & {
const { providerId, apiKey, baseUrl } = connection as MediaConnection & {
providerId: ImageProviderId;
};
const provider = IMAGE_PROVIDERS[providerId];
@@ -106,7 +106,10 @@ export async function POST(request: NextRequest) {
);
const result = await generateImage(
withMediaProviderFetch({ providerId, apiKey: apiKey ?? '', baseUrl, model }, managed),
withMediaProviderFetch(
{ providerId, apiKey: apiKey ?? '', baseUrl, model },
mediaNetworkPolicy(connection),
),
sizedOptions,
);
+6 -3
View File
@@ -28,7 +28,7 @@ import type { VideoProviderId, VideoGenerationOptions } from '@/lib/media/types'
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { validateClientBaseUrl } from '@/lib/server/ssrf-guard';
import { withVideoProviderFetch } from '@/lib/server/media-provider-fetch';
import { mediaNetworkPolicy, withVideoProviderFetch } from '@/lib/server/media-provider-fetch';
import {
mediaResolutionResponse,
RequestedProviderRefusedError,
@@ -63,7 +63,7 @@ export async function POST(request: NextRequest) {
throw error;
}
const providerId = connection.providerId as VideoProviderId;
const { apiKey, baseUrl, managed } = connection;
const { apiKey, baseUrl } = connection;
if (!apiKey) {
return apiError(
'MISSING_API_KEY',
@@ -98,7 +98,10 @@ export async function POST(request: NextRequest) {
);
const result = await generateVideo(
withVideoProviderFetch({ providerId, apiKey, baseUrl, model }, managed),
withVideoProviderFetch(
{ providerId, apiKey, baseUrl, model },
mediaNetworkPolicy(connection),
),
options,
);
+2 -2
View File
@@ -3,7 +3,7 @@ import type { AssetStore } from '@openmaic/storage';
import { Type, type Static } from 'typebox';
import { generateImage, IMAGE_PROVIDERS } from '@/lib/media/image-providers';
import { managedMediaProviderFetch, mediaProviderFetch } from '@/lib/server/media-provider-fetch';
import { mediaNetworkPolicy, mediaTransports } from '@/lib/server/media-provider-fetch';
import { serverMediaConnection } from '@/lib/server/model-config/media';
import type {
ImageGenerationConfig,
@@ -200,7 +200,7 @@ function slotImageProvider(ownerId: string | undefined) {
...(resolved.baseUrl ? { baseUrl: resolved.baseUrl } : {}),
// A slot without a model uses the provider's first catalogue model.
model: resolved.modelId ?? IMAGE_PROVIDERS[providerId]?.models?.[0]?.id,
fetchImpl: resolved.managed ? managedMediaProviderFetch : mediaProviderFetch,
fetchImpl: mediaTransports(mediaNetworkPolicy(resolved)).fetchImpl,
};
},
};
+2 -8
View File
@@ -4,12 +4,7 @@ import { nanoid } from 'nanoid';
import { Type, type Static } from 'typebox';
import { generateVideo, normalizeVideoOptions, VIDEO_PROVIDERS } from '@/lib/media/video-providers';
import {
managedMediaDownloadFetch,
managedMediaProviderFetch,
mediaDownloadFetch,
mediaProviderFetch,
} from '@/lib/server/media-provider-fetch';
import { mediaNetworkPolicy, mediaTransports } from '@/lib/server/media-provider-fetch';
import type { MediaConnection } from '@/lib/server/model-config/media';
import type {
VideoGenerationConfig,
@@ -313,8 +308,7 @@ function slotVideoConfig(
...(connection.baseUrl ? { baseUrl: connection.baseUrl } : {}),
// A slot without a model uses the provider's first catalogue model.
model: connection.modelId ?? VIDEO_PROVIDERS[providerId]?.models?.[0]?.id,
fetchImpl: connection.managed ? managedMediaProviderFetch : mediaProviderFetch,
downloadFetchImpl: connection.managed ? managedMediaDownloadFetch : mediaDownloadFetch,
...mediaTransports(mediaNetworkPolicy(connection)),
};
}
+3 -9
View File
@@ -14,12 +14,7 @@ import { classroomMediaMimeType } from '@/lib/server/classroom-storage';
import { storeGeneratedAsset, type GeneratedAssetKind } from '@/lib/server/store-generated-asset';
import { generateImage } from '@/lib/media/image-providers';
import { generateVideo, normalizeVideoOptions } from '@/lib/media/video-providers';
import {
managedMediaDownloadFetch,
managedMediaProviderFetch,
mediaDownloadFetch,
mediaProviderFetch,
} from '@/lib/server/media-provider-fetch';
import { mediaNetworkPolicy, mediaTransports } from '@/lib/server/media-provider-fetch';
import { generateTTS, TTSRateLimitError } from '@/lib/audio/tts-providers';
import { DEFAULT_TTS_VOICES, DEFAULT_TTS_MODELS, TTS_PROVIDERS } from '@/lib/audio/constants';
import { IMAGE_PROVIDERS } from '@/lib/media/image-providers';
@@ -198,7 +193,7 @@ export async function generateMediaForClassroom(
apiKey,
baseUrl: image!.baseUrl,
model,
fetchImpl: image!.managed ? managedMediaProviderFetch : mediaProviderFetch,
fetchImpl: mediaTransports(mediaNetworkPolicy(image!)).fetchImpl,
},
resolveImageSize(
{ prompt: req.prompt, aspectRatio: req.aspectRatio || '16:9' },
@@ -276,8 +271,7 @@ export async function generateMediaForClassroom(
apiKey,
baseUrl: video!.baseUrl,
model,
fetchImpl: video!.managed ? managedMediaProviderFetch : mediaProviderFetch,
downloadFetchImpl: video!.managed ? managedMediaDownloadFetch : mediaDownloadFetch,
...mediaTransports(mediaNetworkPolicy(video!)),
},
normalized,
);
+58 -7
View File
@@ -60,12 +60,66 @@ export const managedMediaDownloadFetch: MediaProviderFetch = (input, init) =>
redirectAllowLocalNetworks: resolveAllowLocalNetworks(),
});
const PUBLIC_MEDIA_PROVIDER_POLICY: ProviderFetchPolicy = {
allowLocalNetworks: false,
rejectRedirects: true,
};
/**
* Transport for a provider a workspace configured (RFC #1701): its endpoint is
* user input, so it runs under the strict public policy whatever the operator
* opted into for their own backends.
*/
export const publicMediaProviderFetch: MediaProviderFetch = (input, init) =>
providerFetch(input, init, PUBLIC_MEDIA_PROVIDER_POLICY);
/** {@link mediaDownloadFetch} under the strict public policy, every hop included. */
export const publicMediaDownloadFetch: MediaProviderFetch = (input, init) =>
providerFetch(input, init, { allowLocalNetworks: false });
/**
* Whose address policy a connection runs under: the operator's own provider
* (`managed`, local networks allowed), a workspace's (`public`), or a
* deprecated per-request one (`operator`, the operator's opt-in).
*/
export type MediaNetworkPolicy = 'managed' | 'operator' | 'public';
/** The policy for a resolved media connection. */
export function mediaNetworkPolicy(connection: {
managed: boolean;
origin: 'configuration' | 'request' | 'default';
}): MediaNetworkPolicy {
if (connection.managed) return 'managed';
return connection.origin === 'configuration' ? 'public' : 'operator';
}
const PROVIDER_FETCH: Record<MediaNetworkPolicy, MediaProviderFetch> = {
managed: managedMediaProviderFetch,
operator: mediaProviderFetch,
public: publicMediaProviderFetch,
};
const DOWNLOAD_FETCH: Record<MediaNetworkPolicy, MediaProviderFetch> = {
managed: managedMediaDownloadFetch,
operator: mediaDownloadFetch,
public: publicMediaDownloadFetch,
};
/** The request and download transports for a policy (true/false: managed/operator). */
export function mediaTransports(policy: MediaNetworkPolicy | boolean): {
fetchImpl: MediaProviderFetch;
downloadFetchImpl: MediaProviderFetch;
} {
const key = policy === true ? 'managed' : policy === false ? 'operator' : policy;
return { fetchImpl: PROVIDER_FETCH[key], downloadFetchImpl: DOWNLOAD_FETCH[key] };
}
/** `config` with the pinned media transport for its provider installed. */
export function withMediaProviderFetch<T extends object>(
config: T,
managed: boolean,
policy: MediaNetworkPolicy | boolean,
): T & { fetchImpl: MediaProviderFetch } {
return { ...config, fetchImpl: managed ? managedMediaProviderFetch : mediaProviderFetch };
return { ...config, fetchImpl: mediaTransports(policy).fetchImpl };
}
/**
@@ -74,10 +128,7 @@ export function withMediaProviderFetch<T extends object>(
*/
export function withVideoProviderFetch<T extends object>(
config: T,
managed: boolean,
policy: MediaNetworkPolicy | boolean,
): T & { fetchImpl: MediaProviderFetch; downloadFetchImpl: MediaProviderFetch } {
return {
...withMediaProviderFetch(config, managed),
downloadFetchImpl: managed ? managedMediaDownloadFetch : mediaDownloadFetch,
};
return { ...config, ...mediaTransports(policy) };
}
+41
View File
@@ -0,0 +1,41 @@
/**
* A workspace's media provider runs under the strict public policy even when
* the operator opted into local networks for their own backends; the
* deprecated per-request path keeps the operator's opt-in.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mediaNetworkPolicy, mediaTransports } from '@/lib/server/media-provider-fetch';
import { destroyAudioProviderDispatchersForTests } from '@/lib/server/provider-fetch';
import { closeLoopbackServers, startLoopback } from '@/tests/helpers/loopback-servers';
afterEach(async () => {
vi.unstubAllEnvs();
await closeLoopbackServers();
await destroyAudioProviderDispatchersForTests();
});
describe('media network policy', () => {
it('names the policy of each connection', () => {
expect(mediaNetworkPolicy({ managed: true, origin: 'configuration' })).toBe('managed');
expect(mediaNetworkPolicy({ managed: false, origin: 'configuration' })).toBe('public');
expect(mediaNetworkPolicy({ managed: false, origin: 'request' })).toBe('operator');
});
it('refuses a local endpoint for a workspace provider despite ALLOW_LOCAL_NETWORKS', async () => {
vi.stubEnv('ALLOW_LOCAL_NETWORKS', 'true');
const server = await startLoopback((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{}');
});
const url = `http://127.0.0.1:${server.port}/v1/images`;
const operator = mediaTransports('operator');
expect((await operator.fetchImpl(url, { method: 'POST', body: '{}' })).status).toBe(200);
const workspace = mediaTransports('public');
await expect(workspace.fetchImpl(url, { method: 'POST', body: '{}' })).rejects.toThrow();
await expect(workspace.downloadFetchImpl(url, {})).rejects.toThrow();
expect(server.requests()).toBe(1);
});
});