From 4c1b97f210f9df8e2c1e9764360c0082d96d068f Mon Sep 17 00:00:00 2001 From: 134 Date: Sun, 12 Jul 2026 08:48:25 +0800 Subject: [PATCH] fix: allow proxied Pascal scene creation --- docs/pascal-production-deployment.md | 3 ++ scripts/deploy-pascal-production.sh | 3 ++ .../editor/lib/scene-api-security.test.ts | 32 +++++++++++++++++++ .../apps/editor/lib/scene-api-security.ts | 12 ++++++- 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/pascal-production-deployment.md b/docs/pascal-production-deployment.md index a4fc5d8aa..af5fef7ef 100644 --- a/docs/pascal-production-deployment.md +++ b/docs/pascal-production-deployment.md @@ -98,6 +98,9 @@ sudo env PASCAL_SMOKE_CONFIRM=1 pnpm pascal:smoke | `PASCAL_BUN_VERSION` | 固定 Bun 版本 | | `PASCAL_BACKUP_DIR` | 环境与 Nginx 配置备份目录 | +`PASCAL_PUBLIC_ORIGIN` 会同时写入 Pascal 的场景 API 允许来源,用于正确 +识别 Nginx 后的 HTTPS 同域请求,不应配置为 `*`。 + ## 运行维护 ```bash diff --git a/scripts/deploy-pascal-production.sh b/scripts/deploy-pascal-production.sh index b727d13f7..b95865e72 100755 --- a/scripts/deploy-pascal-production.sh +++ b/scripts/deploy-pascal-production.sh @@ -231,6 +231,9 @@ write_pascal_env() { printf 'NEXT_PUBLIC_PASCAL_BASE_PATH=%s\n' "$PASCAL_BASE_PATH" printf 'PASCAL_REQUIRE_HOST_USER=true\n' printf 'PASCAL_DATA_DIR=%s\n' "$PASCAL_DATA_DIR" + if [ -n "$PASCAL_PUBLIC_ORIGIN" ]; then + printf 'PASCAL_SCENE_API_ORIGINS=%s\n' "${PASCAL_PUBLIC_ORIGIN%/}" + fi printf 'PASCAL_SCENE_API_TOKEN=%s\n' "$secret" printf 'PASCAL_AGENT_PROXY_SECRET=%s\n' "$secret" printf 'PASCAL_AGENT_API_KEY=%s\n' "$secret" diff --git a/services/pascal-editor/apps/editor/lib/scene-api-security.test.ts b/services/pascal-editor/apps/editor/lib/scene-api-security.test.ts index 6494b5dd7..f0cc7d277 100644 --- a/services/pascal-editor/apps/editor/lib/scene-api-security.test.ts +++ b/services/pascal-editor/apps/editor/lib/scene-api-security.test.ts @@ -47,6 +47,38 @@ test('accepts bearer token auth when configured', () => { expect(guardSceneApiRequest(request)).toBeNull() }) +test('accepts the public HTTPS origin behind a trusted reverse proxy', () => { + process.env.PASCAL_SCENE_API_TOKEN = 'secret' + const request = new Request('http://bbyy.ai/pascal-service/api/scenes', { + headers: { + host: 'bbyy.ai', + origin: 'https://bbyy.ai', + 'x-forwarded-host': 'bbyy.ai', + 'x-forwarded-proto': 'https', + 'x-pascal-scene-token': 'secret', + }, + }) + + expect(guardSceneApiRequest(request)).toBeNull() +}) + +test('rejects an unrelated origin behind the reverse proxy', async () => { + process.env.PASCAL_SCENE_API_TOKEN = 'secret' + const request = new Request('http://bbyy.ai/pascal-service/api/scenes', { + headers: { + host: 'bbyy.ai', + origin: 'https://evil.example', + 'x-forwarded-host': 'bbyy.ai', + 'x-forwarded-proto': 'https', + 'x-pascal-scene-token': 'secret', + }, + }) + + const response = guardSceneApiRequest(request) + expect(response?.status).toBe(403) + expect(await response?.json()).toEqual({ error: 'origin_not_allowed' }) +}) + test('applies configured CORS origins for preflight', () => { process.env.PASCAL_SCENE_API_ORIGINS = 'https://app.example' const request = new Request('https://editor.example/api/scenes', { diff --git a/services/pascal-editor/apps/editor/lib/scene-api-security.ts b/services/pascal-editor/apps/editor/lib/scene-api-security.ts index 2dd3819a6..f99a9731e 100644 --- a/services/pascal-editor/apps/editor/lib/scene-api-security.ts +++ b/services/pascal-editor/apps/editor/lib/scene-api-security.ts @@ -145,10 +145,20 @@ function configuredOrigins(): Set { function isSameOrigin(request: Request, origin: string): boolean { const parsedOrigin = parseUrl(origin) if (!parsedOrigin) return false - const requestUrl = new URL(request.url) + const forwardedProto = firstForwardedValue(request.headers.get('x-forwarded-proto')) + const forwardedHost = firstForwardedValue(request.headers.get('x-forwarded-host')) + const forwardedUrl = + forwardedProto && forwardedHost && /^(?:http|https)$/.test(forwardedProto) + ? parseUrl(`${forwardedProto}://${forwardedHost}`) + : null + const requestUrl = forwardedUrl ?? new URL(request.url) return normalizeOrigin(parsedOrigin) === normalizeOrigin(requestUrl) } +function firstForwardedValue(value: string | null): string | null { + return value?.split(',')[0]?.trim().toLowerCase() || null +} + function isLoopbackRequest(request: Request): boolean { const host = request.headers.get('host') ?? new URL(request.url).host return isLoopbackHostname(stripPort(host))