mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-15 05:11:24 +08:00
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:
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user