diff --git a/CLAUDE.md b/CLAUDE.md index a515f04..0fef290 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -209,7 +209,7 @@ npm run test:coverage # 生成覆盖率报告 1. **上下文检索优先级**: 开始任务或修改代码前,优先使用 `mcp__fast_context__fast_context_search` 做语义检索;再用 `rg`/`sed`/`nl` 精确核验文件、符号和行号 2. **不要再引用旧的 `mcp__ace-tool__search_context`**: 该仓库当前统一使用 `mcp__fast_context__fast_context_search` 作为首选上下文检索方式 -3. **Giffgaff OAuth 的 `GIFFGAFF_REDIRECT_URI` 只影响服务端 token exchange**: 前端授权跳转仍由 `src/giffgaff/js/modules/oauth-handler.js` 中的 `oauthConfig.redirectUri` 控制,Netlify 环境变量不会自动改写前端授权 URL +3. **Giffgaff OAuth 的 `GIFFGAFF_REDIRECT_URI` 只影响服务端 token exchange**: 前端授权跳转仍由 `src/giffgaff/js/modules/api-config.js` 中的 `oauthConfig.redirectUri` 控制,Netlify 环境变量不会自动改写前端授权 URL 4. **修改 Functions 时**: 必须通过 `withAuth` 中间件包装 handler 5. **修改前端模块时**: 使用相对路径导入模块 6. **添加新 Function 时**: 在 `server.js` 中注册 Express 路由 (本地开发) diff --git a/env.example b/env.example index 9ee43d0..bf021d6 100644 --- a/env.example +++ b/env.example @@ -36,6 +36,10 @@ RECAPTCHA_SECRET_KEY= # ❌ 禁止使用默认值或简单密码 ACCESS_KEY= +# Simyo 代理客户端令牌 +# ⚠️ 启用 Simyo 代理时必填;server.js 不再提供默认回退 +SIMYO_CLIENT_TOKEN= + # Giffgaff OAuth token exchange 回调 URI # 注意:该变量只影响服务端 token exchange;前端授权跳转 URI 仍在 src/giffgaff/js/modules/api-config.js 中配置。 GIFFGAFF_REDIRECT_URI=giffgaff://auth/callback/ diff --git a/netlify/edge-functions/bff-proxy.js b/netlify/edge-functions/bff-proxy.js index d3e9bd1..4ef2af0 100644 --- a/netlify/edge-functions/bff-proxy.js +++ b/netlify/edge-functions/bff-proxy.js @@ -77,13 +77,16 @@ export default async (request, context) => { const sameOrigin = requestOrigin === url.origin; const configuredOrigin = allowedOrigins.includes(requestOrigin); const corsOrigin = sameOrigin || configuredOrigin ? requestOrigin : ''; + const allowMissingOriginForPublicGet = targetName === 'public-config' && request.method === 'GET'; + const fallbackCorsOrigin = allowedOrigins[0] || DEFAULT_ALLOWED_ORIGIN; + const errorCorsHeaders = buildCorsHeaders(corsOrigin || fallbackCorsOrigin); - if (!corsOrigin) { + if (!corsOrigin && !allowMissingOriginForPublicGet) { console.warn(`[BFF] ${ts} | blocked origin=${requestOrigin || 'missing'} target=${targetName}`); - return jsonResponse(403, { error: 'Forbidden', message: 'Origin not allowed' }); + return jsonResponse(403, { error: 'Forbidden', message: 'Origin not allowed' }, errorCorsHeaders); } - const corsHeaders = buildCorsHeaders(corsOrigin); + const corsHeaders = corsOrigin ? buildCorsHeaders(corsOrigin) : {}; if (request.method === 'OPTIONS') { return new Response('', { status: 200, headers: corsHeaders }); } diff --git a/netlify/functions/CLAUDE.md b/netlify/functions/CLAUDE.md index 64e6a36..fca1dce 100644 --- a/netlify/functions/CLAUDE.md +++ b/netlify/functions/CLAUDE.md @@ -58,7 +58,7 @@ const schema = { - **axios**: HTTP 请求 (Functions 内部调用外部 API) - **cheerio**: HTML 解析 (爬虫场景) - **@sentry/node**: 错误监控 (生产环境) -- **环境变量**: `ACCESS_KEY`, `ALLOWED_ORIGIN`, `GIFFGAFF_CLIENT_ID`, `GIFFGAFF_CLIENT_SECRET`, `GIFFGAFF_REDIRECT_URI`, `SENTRY_DSN` +- **环境变量**: `ACCESS_KEY`, `ALLOWED_ORIGIN`, `GIFFGAFF_CLIENT_ID`, `GIFFGAFF_CLIENT_SECRET`, `GIFFGAFF_REDIRECT_URI`, `SIMYO_CLIENT_TOKEN`, `SENTRY_DSN` ## 数据模型 diff --git a/netlify/functions/_shared/middleware.js b/netlify/functions/_shared/middleware.js index d75c4c3..5c22236 100644 --- a/netlify/functions/_shared/middleware.js +++ b/netlify/functions/_shared/middleware.js @@ -77,12 +77,12 @@ function authenticate(event) { return { preflight: true, origin: requestOrigin }; } - // 非预检请求:验证来源 + // 非预检请求:内部调用可跳过 Origin gate,但仍需在后续校验内部密钥 if (!requestOrigin && !isInternalCall) { throw new AuthError('Origin not allowed', 403); } - if (!isAllowedOrigin(requestOrigin)) { + if (requestOrigin && !isAllowedOrigin(requestOrigin)) { throw new AuthError('Origin not allowed', 403); } @@ -296,17 +296,19 @@ function withAuth(handler, options = {}) { // 执行业务逻辑 const result = await handler(event, context, { auth, body: parsedBody }); + const responseOrigin = auth.origin; + // 确保返回正确的响应格式 if (!result.statusCode) { return { statusCode: 200, - headers: createHeaders(auth.origin), + headers: createHeaders(responseOrigin), body: JSON.stringify(result) }; } // 合并默认头 - result.headers = createHeaders(auth.origin, result.headers || {}); + result.headers = createHeaders(responseOrigin, result.headers || {}); return result; } catch (error) { diff --git a/netlify/functions/giffgaff-graphql.js b/netlify/functions/giffgaff-graphql.js index 592bfaa..0490c79 100644 --- a/netlify/functions/giffgaff-graphql.js +++ b/netlify/functions/giffgaff-graphql.js @@ -7,9 +7,12 @@ const axios = require('axios'); const { withAuth, validateInput, AuthError } = require('./_shared/middleware'); function getInternalHeaders() { + if (!process.env.ACCESS_KEY) { + throw new AuthError('ACCESS_KEY 未配置', 500); + } return { 'Content-Type': 'application/json', - 'x-esim-key': process.env.ACCESS_KEY || '' + 'x-esim-key': process.env.ACCESS_KEY }; } @@ -194,7 +197,7 @@ exports.handler = withAuth(async (event, context, { auth, body }) => { timeout: 15000 }); - if (r.data?.success && r.data?.accessToken) { + if (r.data?.valid && r.data?.accessToken) { accessToken = r.data.accessToken; requestHeaders['Authorization'] = `Bearer ${accessToken}`; console.log(`[GGQL] ${ts} | token refreshed, retrying upstream call`); @@ -206,7 +209,7 @@ exports.handler = withAuth(async (event, context, { auth, body }) => { console.log(`[GGQL] ${ts} | retry OK: status=${response.status}`); break; // 重试成功,跳出循环 } else { - console.error(`[GGQL] ${ts} | cookie refresh failed: success=${r.data?.success}`); + console.error(`[GGQL] ${ts} | cookie refresh failed: valid=${r.data?.valid}`); throw err; } } catch (reErr) { diff --git a/netlify/functions/giffgaff-mfa-challenge.js b/netlify/functions/giffgaff-mfa-challenge.js index 798b768..0bfe896 100644 --- a/netlify/functions/giffgaff-mfa-challenge.js +++ b/netlify/functions/giffgaff-mfa-challenge.js @@ -7,9 +7,12 @@ const axios = require('axios'); const { withAuth, validateInput, AuthError } = require('./_shared/middleware'); function getInternalHeaders() { + if (!process.env.ACCESS_KEY) { + throw new AuthError('ACCESS_KEY 未配置', 500); + } return { 'Content-Type': 'application/json', - 'x-esim-key': process.env.ACCESS_KEY || '' + 'x-esim-key': process.env.ACCESS_KEY }; } @@ -80,7 +83,7 @@ exports.handler = withAuth(async (event, context, { auth, body }) => { headers: getInternalHeaders(), timeout: 30000 }); - if (cookieVerifyResponse.data?.success && cookieVerifyResponse.data?.accessToken) { + if (cookieVerifyResponse.data?.valid && cookieVerifyResponse.data?.accessToken) { accessToken = cookieVerifyResponse.data.accessToken; } } catch (cookieError) { @@ -185,7 +188,7 @@ exports.handler = withAuth(async (event, context, { auth, body }) => { timeout: 30000 }); - if (cookieVerifyResponse.data?.success && cookieVerifyResponse.data?.accessToken) { + if (cookieVerifyResponse.data?.valid && cookieVerifyResponse.data?.accessToken) { let refreshed = cookieVerifyResponse.data.accessToken; const looksLikeJwt = typeof refreshed === 'string' && refreshed.includes('.') && refreshed.length > 200; diff --git a/netlify/functions/giffgaff-mfa-validation.js b/netlify/functions/giffgaff-mfa-validation.js index b54ccf9..9671f08 100644 --- a/netlify/functions/giffgaff-mfa-validation.js +++ b/netlify/functions/giffgaff-mfa-validation.js @@ -7,9 +7,12 @@ const axios = require('axios'); const { withAuth, validateInput, AuthError } = require('./_shared/middleware'); function getInternalHeaders() { + if (!process.env.ACCESS_KEY) { + throw new AuthError('ACCESS_KEY 未配置', 500); + } return { 'Content-Type': 'application/json', - 'x-esim-key': process.env.ACCESS_KEY || '' + 'x-esim-key': process.env.ACCESS_KEY }; } @@ -61,7 +64,7 @@ exports.handler = withAuth(async (event, context, { auth, body }) => { timeout: 30000 }); - if (cookieVerifyResponse.data?.success && cookieVerifyResponse.data?.accessToken) { + if (cookieVerifyResponse.data?.valid && cookieVerifyResponse.data?.accessToken) { accessToken = cookieVerifyResponse.data.accessToken; } } catch (cookieError) { @@ -105,7 +108,7 @@ exports.handler = withAuth(async (event, context, { auth, body }) => { timeout: 30000 }); - if (cookieVerifyResponse.data?.success && cookieVerifyResponse.data?.accessToken) { + if (cookieVerifyResponse.data?.valid && cookieVerifyResponse.data?.accessToken) { const refreshed = cookieVerifyResponse.data.accessToken; response = await sendValidation(refreshed); } else { diff --git a/server.js b/server.js index b377c64..6d75993 100644 --- a/server.js +++ b/server.js @@ -185,6 +185,14 @@ app.use('/api/simyo/*', (req, res) => { return res.status(200).end(); } + const simyoClientToken = process.env.SIMYO_CLIENT_TOKEN; + if (!simyoClientToken) { + return res.status(500).json({ + error: 'Server Misconfigured', + message: 'SIMYO_CLIENT_TOKEN 未配置' + }); + } + // 代理请求 const axios = require('axios'); const config = { @@ -193,7 +201,7 @@ app.use('/api/simyo/*', (req, res) => { headers: { 'Content-Type': 'application/json', 'User-Agent': req.headers['user-agent'] || process.env.SIMYO_USER_AGENT || DEFAULT_SIMYO_USER_AGENT, - 'X-Client-Token': process.env.SIMYO_CLIENT_TOKEN || '', + 'X-Client-Token': simyoClientToken, 'X-Client-Platform': process.env.SIMYO_CLIENT_PLATFORM || DEFAULT_SIMYO_CLIENT_PLATFORM, 'X-Client-Version': process.env.SIMYO_CLIENT_VERSION || DEFAULT_SIMYO_CLIENT_VERSION, ...(req.headers['x-session-token'] ? { 'X-Session-Token': req.headers['x-session-token'] } : {}) diff --git a/src/js/modules/api-service.js b/src/js/modules/api-service.js index 749e82b..ba28610 100644 --- a/src/js/modules/api-service.js +++ b/src/js/modules/api-service.js @@ -64,23 +64,26 @@ class APIService { * Execute the actual HTTP request with retry */ async executeRequest(url, options) { - const timeout = this.createTimeoutSignal(options.timeout || this.timeout); const fetchOptions = { method: options.method || 'GET', headers: { 'Content-Type': 'application/json', ...options.headers }, - signal: timeout.signal }; if (options.body) { fetchOptions.body = JSON.stringify(options.body); } - try { - return await retry(async () => { - const response = await fetch(url, fetchOptions); + return await retry(async () => { + const timeout = this.createTimeoutSignal(options.timeout || this.timeout); + try { + const requestOptions = { + ...fetchOptions, + signal: timeout.signal + }; + const response = await fetch(url, requestOptions); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); @@ -92,10 +95,10 @@ class APIService { } return response.text(); - }, this.retries); - } finally { - timeout.clear(); - } + } finally { + timeout.clear(); + } + }, this.retries); } /** diff --git a/tests/modules/api-service.test.js b/tests/modules/api-service.test.js index e3dd702..9762916 100644 --- a/tests/modules/api-service.test.js +++ b/tests/modules/api-service.test.js @@ -103,6 +103,31 @@ describe('APIService', () => { }); describe('错误处理', () => { + + + it('应该为每次重试创建新的超时信号', async () => { + const timeoutService = new APIService({ baseURL: 'https://api.test.com', timeout: 5000, retries: 2 }); + const firstSignal = { aborted: true }; + const secondSignal = { aborted: false }; + const createTimeoutSpy = jest.spyOn(timeoutService, 'createTimeoutSignal') + .mockReturnValueOnce({ signal: firstSignal, clear: jest.fn() }) + .mockReturnValueOnce({ signal: secondSignal, clear: jest.fn() }); + + fetch.mockImplementationOnce(() => Promise.reject(new Error('timeout'))); + fetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => 'application/json' }, + json: () => Promise.resolve({ ok: true }) + }); + + const result = await timeoutService.get('/retry-timeout'); + + expect(result).toEqual({ ok: true }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(createTimeoutSpy).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[0][1].signal).toBe(firstSignal); + expect(fetch.mock.calls[1][1].signal).toBe(secondSignal); + }); it('应该在 HTTP 错误时抛出异常', async () => { // mock fetch 返回 error response,但重试会多次调用 fetch.mockResolvedValue({ diff --git a/tests/security/bff-proxy.test.js b/tests/security/bff-proxy.test.js index 5e87e9c..9859d00 100644 --- a/tests/security/bff-proxy.test.js +++ b/tests/security/bff-proxy.test.js @@ -3,6 +3,7 @@ describe('BFF proxy guardrails', () => { const OriginalRequest = global.Request; const OriginalResponse = global.Response; const OriginalDeno = global.Deno; + const OriginalFetch = global.fetch; class MockHeaders { constructor(init = {}) { @@ -92,6 +93,7 @@ describe('BFF proxy guardrails', () => { jest.restoreAllMocks(); global.Request = OriginalRequest; global.Response = OriginalResponse; + global.fetch = OriginalFetch; global.Deno = OriginalDeno; }); @@ -106,7 +108,21 @@ describe('BFF proxy guardrails', () => { expect(response.status).toBe(404); }); - it('blocks missing-origin calls to protected BFF targets', async () => { + + + it('allows same-origin public GET requests without Origin', async () => { + const mod = await import('../../netlify/edge-functions/bff-proxy.js'); + const request = new Request('https://example.com/bff/public-config', { + method: 'GET' + }); + + const response = await mod.default(request); + + expect(response.status).toBe(200); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('returns 403 for missing-origin protected POST requests', async () => { const mod = await import('../../netlify/edge-functions/bff-proxy.js'); const request = new Request('https://example.com/bff/verify-cookie', { method: 'POST', @@ -115,7 +131,9 @@ describe('BFF proxy guardrails', () => { }); const response = await mod.default(request); + expect(response.status).toBe(403); + expect(response.headers.get('access-control-allow-origin')).toBe('https://esim.cosr.eu.org'); }); it('forwards allowlisted BFF targets with the server x-esim-key', async () => { diff --git a/tests/security/functions-auth.test.js b/tests/security/functions-auth.test.js index dc98003..1c4e6c7 100644 --- a/tests/security/functions-auth.test.js +++ b/tests/security/functions-auth.test.js @@ -49,6 +49,32 @@ describe('Functions authentication hardening', () => { expect(result.authorized).toBe(true); }); + + it('allows OPTIONS preflight for allowed origin', () => { + const { middleware } = loadModules(); + + const result = middleware.authenticate({ + httpMethod: 'OPTIONS', + headers: { origin: 'https://esim.cosr.eu.org' }, + body: '{}', + queryStringParameters: {} + }); + + expect(result.preflight).toBe(true); + expect(result.origin).toBe('https://esim.cosr.eu.org'); + }); + + it('rejects OPTIONS preflight for disallowed origin', () => { + const { middleware } = loadModules(); + + expect(() => middleware.authenticate({ + httpMethod: 'OPTIONS', + headers: { origin: 'https://evil.example.com' }, + body: '{}', + queryStringParameters: {} + })).toThrow('Origin not allowed'); + }); + it('does not accept authKey from query or body', () => { const { middleware } = loadModules(); const event = { @@ -87,6 +113,27 @@ describe('Functions authentication hardening', () => { expect(form).not.toContain('attacker.example'); }); + + + it('throws a configuration error when ACCESS_KEY is missing for token exchange', async () => { + const { tokenExchange } = loadModules({ ACCESS_KEY: '' }); + + const result = await tokenExchange.handler({ + httpMethod: 'POST', + headers: { + origin: 'https://esim.cosr.eu.org' + }, + body: JSON.stringify({ + code: 'authorization-code', + code_verifier: 'a'.repeat(64) + }), + queryStringParameters: {} + }, { functionName: 'giffgaff-token-exchange' }); + + expect(result.statusCode).toBe(500); + expect(result.body).toContain('ACCESS_KEY'); + }); + it('uses configured GIFFGAFF_REDIRECT_URI for token exchange', async () => { const { axios, tokenExchange } = loadModules({ GIFFGAFF_REDIRECT_URI: 'giffgaff://custom/callback/' diff --git a/tests/security/server-routes.test.js b/tests/security/server-routes.test.js index 0d6d679..198e864 100644 --- a/tests/security/server-routes.test.js +++ b/tests/security/server-routes.test.js @@ -24,7 +24,7 @@ describe('Local server route coverage', () => { const app = require('../../server.js'); const routes = app.locals.bffRoutes; - expect(routes).toEqual(expect.arrayContaining([ + const expectedRoutes = [ '/bff/giffgaff-token-exchange', '/bff/giffgaff-graphql', '/bff/giffgaff-mfa-challenge', @@ -33,6 +33,8 @@ describe('Local server route coverage', () => { '/bff/auto-activate-esim', '/bff/verify-cookie', '/bff/public-config' - ])); + ]; + + expect([...routes].sort()).toEqual([...expectedRoutes].sort()); }); });