diff --git a/.env.example b/.env.example index 06d99a6..a43ae23 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,8 @@ REDIS_URL=redis://127.0.0.1:6379 HTTP_API_MASTER_KEY=replace-with-at-least-32-random-characters # Customer-facing HTTP API origin returned by the real backend and shown in copied integration parameters. HTTP_API_PUBLIC_ORIGIN=https://api.example.com +# Keep false in production. Only isolated test environments without TLS may opt in to HTTP. +HTTP_API_ALLOW_INSECURE_ORIGIN=false API_ENABLE_SEND_WORKER=true API_SEND_WORKER_CONCURRENCY=50 API_WORKER_DATABASE_URL= diff --git a/api/src/open-api/open-api.service.spec.ts b/api/src/open-api/open-api.service.spec.ts index 638642d..6c472cb 100644 --- a/api/src/open-api/open-api.service.spec.ts +++ b/api/src/open-api/open-api.service.spec.ts @@ -26,6 +26,27 @@ describe('OpenApiService', () => { } }); + it('only permits a plain HTTP public origin when an isolated test environment explicitly opts in', async () => { + const previousOrigin = process.env.HTTP_API_PUBLIC_ORIGIN; + const previousAllowInsecure = process.env.HTTP_API_ALLOW_INSECURE_ORIGIN; + process.env.HTTP_API_PUBLIC_ORIGIN = 'http://100.93.204.60:12026/'; + delete process.env.HTTP_API_ALLOW_INSECURE_ORIGIN; + const prisma = { + smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }) }, + }; + try { + const service = new OpenApiService(prisma as never, {} as never); + await expect(service.getConfig('app-1')).rejects.toThrow('HTTP_API_ALLOW_INSECURE_ORIGIN'); + process.env.HTTP_API_ALLOW_INSECURE_ORIGIN = 'true'; + await expect(service.getConfig('app-1')).resolves.toEqual(expect.objectContaining({ publicOrigin: 'http://100.93.204.60:12026' })); + } finally { + if (previousOrigin === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN; + else process.env.HTTP_API_PUBLIC_ORIGIN = previousOrigin; + if (previousAllowInsecure === undefined) delete process.env.HTTP_API_ALLOW_INSECURE_ORIGIN; + else process.env.HTTP_API_ALLOW_INSECURE_ORIGIN = previousAllowInsecure; + } + }); + it('replays a completed request for the same idempotency key and body', async () => { const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'completed', responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' } }) }, diff --git a/api/src/open-api/open-api.service.ts b/api/src/open-api/open-api.service.ts index 81a285a..d76be11 100644 --- a/api/src/open-api/open-api.service.ts +++ b/api/src/open-api/open-api.service.ts @@ -428,10 +428,12 @@ function httpApiPublicOrigin() { const configured = process.env.HTTP_API_PUBLIC_ORIGIN?.trim().replace(/\/+$/, ''); if (!configured) return undefined; const url = new URL(configured); - if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) { + const insecureHttpExplicitlyAllowed = process.env.HTTP_API_ALLOW_INSECURE_ORIGIN === 'true' && url.protocol === 'http:'; + if ((url.protocol !== 'https:' && !insecureHttpExplicitlyAllowed) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) { // This value is copied into customer integration parameters, so fail closed instead of - // publishing an insecure or path-dependent endpoint when deployment config is wrong. - throw new Error('HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址'); + // publishing an insecure or path-dependent endpoint unless an isolated test environment + // has explicitly opted into plain HTTP. + throw new Error('HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址;隔离测试环境如需HTTP须显式启用HTTP_API_ALLOW_INSECURE_ORIGIN'); } return url.origin; }