Просмотр исходного кода

fix: allow production origins for transcription API

彭峰 2 месяцев назад
Родитель
Сommit
a5391afab6
3 измененных файлов с 50 добавлено и 4 удалено
  1. 2 2
      .env.example
  2. 5 1
      README.md
  3. 43 1
      test/http-app.test.mjs

+ 2 - 2
.env.example

@@ -2,8 +2,8 @@ NODE_ENV=production
 HOST=0.0.0.0
 PORT=3200
 
-# 浏览器来源白名单,逗号分隔;上线前替换成真实前端域名。
-CORS_ALLOWED_ORIGINS=https://your-web-app.example.com,http://localhost:4200
+# 浏览器来源白名单,逗号分隔,必须与页面 Origin 完全匹配。
+CORS_ALLOWED_ORIGINS=https://www.yuban.co,https://yuban.co,http://localhost:4200,http://127.0.0.1:4200
 
 # 仅允许从这些对象存储域名下载录音。支持 *.example.com 形式。
 ALLOWED_AUDIO_HOSTS=file.yuban.co

+ 5 - 1
README.md

@@ -143,7 +143,11 @@ docker run -d \
 `deploy/nginx.conf.example` 展示了 HTTPS 反向代理配置。不要让 Node 端口直接暴露到公网。生产环境至少要完成:
 
 1. 配置 HTTPS 域名,例如 `business-api.yuban.co`。
-2. 把 `CORS_ALLOWED_ORIGINS` 限制为真实 Angular 域名。
+2. 把 `CORS_ALLOWED_ORIGINS` 限制为真实 Angular 域名(精确匹配,不要使用 `*`):
+
+   ```env
+   CORS_ALLOWED_ORIGINS=https://www.yuban.co,https://yuban.co,http://localhost:4200,http://127.0.0.1:4200
+   ```
 3. 保持 `ALLOWED_AUDIO_HOSTS=file.yuban.co` 或更窄。
 4. 仅在服务器环境变量中保存讯飞密钥。
 5. 监控 `/health`、任务失败率、磁盘容量和 ffmpeg/ffprobe 可用性。

+ 43 - 1
test/http-app.test.mjs

@@ -21,7 +21,12 @@ async function fixture() {
     },
   };
   const config = {
-    corsAllowedOrigins: ['http://localhost:4200'],
+    corsAllowedOrigins: [
+      'https://www.yuban.co',
+      'https://yuban.co',
+      'http://localhost:4200',
+      'http://127.0.0.1:4200',
+    ],
     allowedAudioHosts: ['file.yuban.co'],
     jobRetentionMs: 86_400_000,
     maxActiveJobsPerUser: 2,
@@ -63,6 +68,43 @@ test('健康检查不要求认证', async t => {
   assert.equal(body.service, 'yuban-server');
 });
 
+test('仅允许配置的网页来源通过 CORS 预检', async t => {
+  const app = await fixture();
+  t.after(() => app.close());
+
+  for (const origin of [
+    'https://www.yuban.co',
+    'https://yuban.co',
+    'http://localhost:4200',
+    'http://127.0.0.1:4200',
+  ]) {
+    const response = await fetch(`${app.baseUrl}/recording-transcription/jobs`, {
+      method: 'OPTIONS',
+      headers: {
+        Origin: origin,
+        'Access-Control-Request-Method': 'POST',
+        'Access-Control-Request-Headers': 'Authorization, Content-Type, Idempotency-Key',
+      },
+    });
+    assert.equal(response.status, 204, origin);
+    assert.equal(response.headers.get('access-control-allow-origin'), origin);
+    assert.match(response.headers.get('vary') || '', /Origin/i);
+    assert.match(response.headers.get('access-control-allow-methods') || '', /POST/);
+  }
+
+  const rejected = await fetch(`${app.baseUrl}/recording-transcription/jobs`, {
+    method: 'OPTIONS',
+    headers: {
+      Origin: 'https://untrusted.example.com',
+      'Access-Control-Request-Method': 'POST',
+    },
+  });
+  assert.equal(rejected.status, 403);
+  const body = await rejected.json();
+  assert.equal(body.error.code, 'ORIGIN_NOT_ALLOWED');
+  assert.equal(rejected.headers.has('access-control-allow-origin'), false);
+});
+
 test('创建任务立即返回 202,查询不会暴露音频地址或所有者', async t => {
   const app = await fixture();
   t.after(() => app.close());