From ca482f7feb38c8ff59434565867f29e5d12a1f8f Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 4 Jul 2026 00:39:56 +0800 Subject: [PATCH] fix: reject legacy SMS auth providers in production --- apps/api/src/features/tenant-admin/routes.ts | 13 ++++++++++++- scripts/aliyun-pnvs-provider-contract-test.js | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/api/src/features/tenant-admin/routes.ts b/apps/api/src/features/tenant-admin/routes.ts index c0f9202b..dd433a41 100644 --- a/apps/api/src/features/tenant-admin/routes.ts +++ b/apps/api/src/features/tenant-admin/routes.ts @@ -1,6 +1,7 @@ import { randomBytes } from 'node:crypto'; import type pg from 'pg'; import { HttpError, type RequestContext } from '../../core/http.js'; +import { config as appConfig } from '../../core/config.js'; import { intParam, optionalString, readJsonBody, requiredString, stringParam } from '../../core/request.js'; import { query, queryOne, transaction } from '../../core/db.js'; import { @@ -390,6 +391,14 @@ function defaultAuthSecretScope(provider: string): SecretScope { return 'oauth'; } +function assertProductionAuthProviderAllowed(provider: string, status: string) { + if (!appConfig.isProduction || !['active', 'testing'].includes(status)) return; + const normalized = provider.toLowerCase().replace(/[_\s]/g, '-'); + if (normalized === 'aliyun' || normalized === 'aliyun-sms' || normalized === 'tencent' || normalized === 'tencent-sms') { + throw new HttpError(400, 'Production SMS auth providers must use aliyun-pnvs', 'PRODUCTION_SMS_PROVIDER_MUST_BE_PNVS'); + } +} + function parseSecretScope(value: unknown, fallback: SecretScope): SecretScope { const candidate = (nullableString(value) || fallback) as SecretScope; if (!SECRET_SCOPES.has(candidate)) { @@ -1400,6 +1409,8 @@ export async function upsertAuthProviderRoute(ctx: RequestContext) { const fallbackScope = defaultAuthSecretScope(provider); const secretPayload = parseSecretPayload(body, fallbackScope, provider, provider); const configPublic = objectValue(body.configPublic); + const status = optionalStatus(body.status, AUTH_STATUSES, 'disabled'); + assertProductionAuthProviderAllowed(provider, status); const item = await transaction(async client => { const secret = secretPayload ? await upsertTenantSecret(client, auth, secretPayload) : null; @@ -1420,7 +1431,7 @@ export async function upsertAuthProviderRoute(ctx: RequestContext) { [ auth.tenantId, provider, - optionalStatus(body.status, AUTH_STATUSES, 'disabled'), + status, optionalString(body, 'displayName') || null, publicJsonValue(configPublic), ], diff --git a/scripts/aliyun-pnvs-provider-contract-test.js b/scripts/aliyun-pnvs-provider-contract-test.js index b543705d..895f66c4 100644 --- a/scripts/aliyun-pnvs-provider-contract-test.js +++ b/scripts/aliyun-pnvs-provider-contract-test.js @@ -26,6 +26,7 @@ assert.match(routeSource, /export async function bindPhoneRoute[\s\S]*activeSmsP assert.match(routeSource, /purpose !== 'bind_phone'/, 'phone binding should require bind_phone purpose'); assert.match(tenantProviderConfigSource, /normalized === 'aliyun-pnvs'[\s\S]*return 'sms'/, 'PNVS secrets should default to sms scope'); assert.match(tenantAdminRouteSource, /normalized === 'aliyun-pnvs'[\s\S]*return 'sms'/, 'tenant admin PNVS secrets should default to sms scope'); +assert.match(tenantAdminRouteSource, /assertProductionAuthProviderAllowed[\s\S]*PRODUCTION_SMS_PROVIDER_MUST_BE_PNVS/, 'tenant admin should reject active legacy SMS auth providers in production'); assert.match(deployEnvExample, /AUTH_SMS_PROVIDER=aliyun-pnvs/, 'deploy env example should prefer aliyun-pnvs'); assert.match(readinessSource, /aliyunPnvs/, 'production readiness should validate aliyun-pnvs public config'); assert.match(providerDoc, /SendSmsVerifyCode/, 'provider doc should document PNVS send action');