Skip to content

Commit 778976f

Browse files
committed
fix: remediate 6 pentest findings (2026-05-24)
- #1 consent withdraw: validate confirmation === '確認撤回' - #2 MCP register: redirect_uri allowlist (localhost/claude.ai/cursor) - #3 staging leak: remove dns-prefetch, use CUSTOM_DOMAIN for OAuth - #4 web field: add to URL validation (reject javascript:) - #5 oauth/init: rate limit 10 req/60s per IP - #6 nfc/tap: short-window rate limit 30 req/60s per IP
1 parent ea6e522 commit 778976f

12 files changed

Lines changed: 226 additions & 77 deletions

File tree

.specify/specs/current_spec.md

Lines changed: 78 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,103 @@
1-
# BDD Spec: Session Expired Circuit Breaker
1+
# BDD Spec: Pentest Findings Remediation (2026-05-24)
22

33
## Goal
4-
When a user's auth token expires, the frontend should detect the first 401 response, immediately suppress all further API calls, show ONE clear user-facing message, and transition to the login view — instead of the current behavior where multiple concurrent 401s each trigger independent cleanup, toasts, and redirects.
4+
修復黑箱滲透測試發現的 6 個安全問題,優先處理可導致資料刪除的 consent withdraw 驗證缺失。
55

6-
## Behavioral Unit
7-
**Session Expired Circuit Breaker** — a global flag that short-circuits all API paths on first 401.
8-
9-
## Problem Context
10-
Two independent API call paths exist:
11-
1. `apiCall()` in user-portal-init.js (internal fetch wrapper)
12-
2. `APIClient.fetch()``ErrorPolicy.handle()` in api-client.js / error-policy.js (feature layer)
13-
14-
Both handle 401 independently. Image loads via `<img src="/api/user/received-cards/.../thumbnail">` bypass both entirely. When token expires, concurrent requests create a 401 storm: multiple toasts, duplicate sessionStorage clears, and ErrorPolicy redirects to the same page causing reload loops.
15-
16-
Production logs show: consent/check + multiple received-cards/image requests all hitting 401 within seconds.
17-
18-
## Impacted Modules
19-
- `workers/public/js/error-policy.js` — 401 handler
20-
- `workers/public/js/api-client.js` — fetch wrapper
21-
- `workers/public/js/user-portal-init.js` — internal apiCall() 401 handler
6+
## Behavioral Units
7+
1. **Consent Withdraw Confirmation Validation** (Medium-High)
8+
2. **Web Field URL Scheme Validation** (Low-Medium)
9+
3. **MCP redirect_uri Allowlist** (Medium)
10+
4. **Staging URL Leakage Cleanup** (Medium)
11+
5. **OAuth Init Rate Limiting** (Low)
12+
6. **NFC Tap Rate Limiting** (Low)
2213

2314
## Scenarios
2415

25-
### Scenario 1: First 401 triggers circuit breaker
16+
### Scenario 1.1: Consent withdraw requires exact confirmation text
2617
```gherkin
27-
Given a user is logged in on user-portal
28-
When any API call returns HTTP 401
29-
Then a global session-expired flag is set (window.__sessionExpired = true)
30-
And sessionStorage is cleared (auth_user, csrfToken)
31-
And ONE toast is shown: "登入已過期,請重新登入" (info type, 3s)
32-
And the view transitions to login (showView('login'))
33-
And no page redirect/reload occurs
18+
Given a user is authenticated
19+
When POST /api/consent/withdraw with body {"confirmation": "wrong text"}
20+
Then response is 400 with error code "invalid_confirmation"
21+
And consent status remains unchanged
3422
```
3523

36-
### Scenario 2: Subsequent 401s are suppressed
24+
### Scenario 1.2: Consent withdraw succeeds with correct confirmation
3725
```gherkin
38-
Given the session-expired flag is already set
39-
When another API call would be made (apiCall or APIClient.fetch)
40-
Then the call short-circuits immediately without making a network request
41-
And no additional toast or redirect is triggered
42-
And the caller receives a structured 401 error (for proper error propagation)
26+
Given a user is authenticated with active consent
27+
When POST /api/consent/withdraw with body {"confirmation": "確認撤回"}
28+
Then response is 200
29+
And consent_status is set to "withdrawn"
30+
And deletion_scheduled_at is set to now + 30 days
4331
```
4432

45-
### Scenario 3: ErrorPolicy 401 respects circuit breaker
33+
### Scenario 1.3: Consent withdraw rejects empty/missing confirmation
4634
```gherkin
47-
Given the session-expired flag is already set
48-
When ErrorPolicy.handle() is called with status 401
49-
Then it returns { action: 'none' } without clearing sessionStorage again or redirecting
35+
Given a user is authenticated
36+
When POST /api/consent/withdraw with body {}
37+
Then response is 400 with error code "invalid_confirmation"
5038
```
5139

52-
### Scenario 4: Login resets circuit breaker
40+
### Scenario 2.1: Web field rejects javascript: URL
5341
```gherkin
54-
Given the session-expired flag is set
55-
When the user successfully logs in again
56-
Then the flag is reset to false
57-
And API calls proceed normally
42+
Given a user is authenticated with a card
43+
When PUT /api/user/cards/{uuid} with body {"web": "javascript:alert(1)"}
44+
Then response is 400 with error mentioning "web" field
5845
```
5946

60-
## Implementation Constraints
61-
62-
1. **Shared flag**: Use `window.__sessionExpired` (boolean) — accessible from all JS modules without import.
63-
64-
2. **api-client.js changes**: At the TOP of `APIClient.fetch()`, check `window.__sessionExpired`. If true, return immediately:
65-
```js
66-
if (window.__sessionExpired) {
67-
return { ok: false, status: 401, error: { code: 'SESSION_EXPIRED', message: '登入已過期', retryable: false } };
68-
}
69-
```
47+
### Scenario 2.2: Web field accepts valid https URL
48+
```gherkin
49+
Given a user is authenticated with a card
50+
When PUT /api/user/cards/{uuid} with body {"web": "https://example.com"}
51+
Then response is 200
52+
And card web field is updated
53+
```
7054

71-
3. **error-policy.js changes**: In the 401 handler, check `window.__sessionExpired`:
72-
- If already set → return `{ action: 'none' }` (no-op)
73-
- If not set → set `window.__sessionExpired = true`, then return existing redirect action BUT change the action to 'none' for user-portal context (let apiCall handle the view transition instead of page redirect)
55+
### Scenario 3.1: MCP register rejects non-localhost redirect_uri
56+
```gherkin
57+
Given an unauthenticated client
58+
When POST /mcp/register with redirect_uris ["https://evil.com/callback"]
59+
Then response is 400 with error "invalid_redirect_uri"
60+
```
7461

75-
4. **user-portal-init.js changes**: In `apiCall()` 401 handler:
76-
- Check `window.__sessionExpired` first — if already set, just throw without re-doing cleanup
77-
- If not set → set `window.__sessionExpired = true`, do cleanup, show toast ONCE, showView('login')
78-
- At the TOP of `apiCall()`: if `window.__sessionExpired`, throw immediately without fetch
62+
### Scenario 3.2: MCP register accepts localhost redirect_uri
63+
```gherkin
64+
Given an unauthenticated client
65+
When POST /mcp/register with redirect_uris ["http://localhost:3000/callback"]
66+
Then response is 201 with client_id assigned
67+
```
7968

80-
5. **Login success path**: After successful Google OAuth callback, set `window.__sessionExpired = false`.
69+
### Scenario 3.3: MCP register accepts 127.0.0.1 redirect_uri
70+
```gherkin
71+
Given an unauthenticated client
72+
When POST /mcp/register with redirect_uris ["http://127.0.0.1:8080/callback"]
73+
Then response is 201 with client_id assigned
74+
```
8175

82-
6. **FeatureAPI.executeAction**: Handle `action: 'none'` case — return false (no retry).
76+
### Scenario 4: Staging URL removed from production
77+
```gherkin
78+
Given the production card-display page
79+
When rendered
80+
Then no dns-prefetch or link to staging worker URL exists
81+
And MCP OAuth redirect_uri uses production domain
82+
```
8383

84-
7. **Do NOT change**: Image onerror handlers (already gracefully degrade to SVG icons), api-retry.js (separate concern), backend code.
84+
### Scenario 5: /api/oauth/init rate limited
85+
```gherkin
86+
Given an IP address
87+
When 20 requests to /api/oauth/init within 60 seconds
88+
Then requests beyond limit return 429
89+
```
8590

86-
## Validation Target
87-
- After token expiry, only ONE toast appears regardless of how many concurrent API calls are in flight
88-
- No page reload/redirect loop
89-
- Backend logs show at most 1-2 401s (the ones already in flight), not a continuous stream
90-
- Login view is shown cleanly
91-
- After re-login, all functionality works normally
91+
### Scenario 6: /api/nfc/tap rate limited
92+
```gherkin
93+
Given an IP address
94+
When 30 requests to /api/nfc/tap within 60 seconds
95+
Then requests beyond limit return 429
96+
```
9297

93-
## Expected Outcome
94-
Users see a clean "session expired, please re-login" message once, land on the login screen, and can log back in. No confusion about system being broken.
98+
## Implementation Notes
99+
- Finding #1: Add body parsing + confirmation === '確認撤回' check in handleConsentWithdraw
100+
- Finding #4: Add "web" to urlFields array in validateUserCardData
101+
- Finding #2: Restrict isValidRedirectUri to localhost/127.0.0.1 only
102+
- Finding #3: Remove staging dns-prefetch; fix MCP OAuth redirect_uri env config
103+
- Finding #5/#6: Add Durable Objects rate limiting to oauth init and nfc tap handlers

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,12 @@ Cloudflare Workers (全球邊緣)
6464
| OWASP ZAP | **A** — 51 PASS, 16 WARN, 0 FAIL |
6565
| npm audit | **0** vulnerabilities (241 packages) |
6666
| OSV-Scanner | **0** issues (241 packages) |
67+
| 黑箱滲透測試 | **6** findings, 全部已修復 (2026-05-24) |
6768

6869
9 個安全標頭完整實作(CSP with nonce、HSTS、COEP/COOP/CORP 等)。
6970

7071
> 16 WARN 適用性聲明及完整安全分析見 [docs/wiki/security.md](docs/wiki/security.md)
72+
> 滲透測試報告見 [docs/security/pentest-2026-05-24.md](docs/security/pentest-2026-05-24.md)
7173
7274
## 測試
7375

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# 黑箱滲透測試報告 (2026-05-24)
2+
3+
## 概要
4+
5+
| 項目 | 內容 |
6+
|------|------|
7+
| 目標 | https://db-card.sfan-tech.com/ |
8+
| 日期 | 2026-05-24 |
9+
| 範圍 | 未認證 + 已認證(User Portal)攻擊面 |
10+
| 方法 | 瀏覽器內操作(AppleScript + JS execute) |
11+
12+
## 結果摘要
13+
14+
| 嚴重度 | 數量 | 狀態 |
15+
|--------|------|------|
16+
| Critical | 0 ||
17+
| High | 0 ||
18+
| Medium-High | 1 | ✅ 已修復 |
19+
| Medium | 2 | ✅ 已修復 |
20+
| Low-Medium | 1 | ✅ 已修復 |
21+
| Low | 2 | ✅ 已修復 |
22+
23+
**已通過安全測試**: 15 項(SQL Injection、XSS、CSRF、IDOR、Privilege Escalation、Path Traversal、CORS、Cookie/Session、Type validation、Social link injection、Card revoke/restore、SSRF、Analytics spoofing)
24+
25+
## 發現與修復
26+
27+
### #1 Consent Withdraw 不驗證確認文字 (Medium-High) ✅
28+
29+
**問題**: `POST /api/consent/withdraw` 後端不驗證 `confirmation` 參數,可直接觸發 30 天資料刪除排程。
30+
31+
**修復**: `handlers/consent.ts` — 加入 `body.confirmation === '確認撤回'` 嚴格驗證,不匹配回 400。
32+
33+
### #2 MCP Dynamic Client Registration 接受任意 redirect_uri (Medium) ✅
34+
35+
**問題**: `isValidRedirectUri()` 接受任何 `https://` 域名。
36+
37+
**修復**: `handlers/mcp/oauth-register.ts` — redirect_uri 白名單:
38+
- Loopback: `localhost` / `127.0.0.1`(any port, RFC 8252)
39+
- Claude.ai: `https://claude.ai`
40+
- Cursor: `cursor://anysphere.cursor-mcp`
41+
42+
### #3 Staging Worker URL 洩漏 (Medium) ✅
43+
44+
**問題**: Production card-display 有 dns-prefetch 指向 staging;MCP OAuth redirect_uri 使用 staging URL。
45+
46+
**修復**:
47+
- `card-display.html` — 移除無意義的 same-origin dns-prefetch
48+
- `handlers/mcp/oauth-authorize.ts` — redirect_uri 改用 `env.CUSTOM_DOMAIN || env.WORKER_URL`
49+
50+
### #4 web 欄位接受 javascript: URL (Low-Medium) ✅
51+
52+
**問題**: `validateUserCardData()` 只驗證 `social_*` 欄位,`web` 欄位未經 URL scheme 檢查。
53+
54+
**修復**: `handlers/user/cards.ts``"web"` 加入 `urlFields` 驗證陣列,使用相同的 `validateSocialLink()` 檢查。
55+
56+
### #5 /api/oauth/init 無 Rate Limit (Low) ✅
57+
58+
**問題**: 50 次連續呼叫全部 200。
59+
60+
**修復**: `handlers/oauth-init.ts` — Durable Objects rate limit 10 req/60s per IP。
61+
62+
### #6 /api/nfc/tap 無短窗口 Rate Limit (Low) ✅
63+
64+
**問題**: 100 次連續呼叫正常回應,可用於 UUID 枚舉。
65+
66+
**修復**: `handlers/tap.ts` — Durable Objects rate limit 30 req/60s per IP(在現有 500/day 之前觸發)。
67+
68+
## 驗證
69+
70+
| 驗證項目 | 結果 |
71+
|----------|------|
72+
| TypeScript 編譯 |`tsc --noEmit` 通過 |
73+
| 單元測試 | ✅ 56/56 通過 |
74+
| #2 evil.com 被拒 |`invalid_redirect_uri` |
75+
| #2 localhost 接受 |`client_id` assigned |
76+
| #2 claude.ai 接受 |`client_id` assigned |
77+
| #2 cursor 接受 |`client_id` assigned |
78+
| #2 cursor wrong host 被拒 |`invalid_redirect_uri` |
79+
| #3 staging URL 移除 | ✅ 無 staging 引用 |
80+
| #5 rate limit 觸發 | ✅ 第 11 次 429 |
81+
| #6 rate limit 觸發 | ✅ 第 31 次 429 |

docs/wiki/security.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
| npm audit | 0 vulnerabilities | 241 packages |
88
| OSV-Scanner | 0 issues | 241 packages |
99
| OWASP ZAP | A (51 PASS, 16 WARN, 0 FAIL) | Staging |
10+
| 黑箱滲透測試 | 6 findings, 全部已修復 | Production (2026-05-24) |
11+
12+
> 滲透測試完整報告見 [docs/security/pentest-2026-05-24.md](../security/pentest-2026-05-24.md)
1013
1114
## OWASP ZAP 16 WARN 適用性聲明
1215

workers/public/card-display.html

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
<meta charset="UTF-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
66
<!-- Resource Hints for Performance Optimization -->
7-
<link rel="dns-prefetch" href="https://db-card-staging.csw30454.workers.dev">
8-
<link rel="preconnect" href="https://db-card-staging.csw30454.workers.dev">
97
<meta name="description" content="DB-Card 數位名片系統 - 安全預設數位名片">
108
<title>數位名片 | 名片顯示</title>
119
<link rel="icon" type="image/png" sizes="192x192" href="favicon.png">

workers/public/css/tailwind.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

workers/src/handlers/consent.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,17 @@ export async function handleConsentWithdraw(request: Request, env: Env): Promise
291291
}
292292
const { email } = authResult;
293293

294+
// Validate confirmation text (防止 CSRF/腳本直接觸發撤回)
295+
let body: { confirmation?: string };
296+
try {
297+
body = await request.json();
298+
} catch {
299+
return errorResponse('invalid_confirmation', 'Confirmation text "確認撤回" is required', 400, request);
300+
}
301+
if (body.confirmation !== '確認撤回') {
302+
return errorResponse('invalid_confirmation', 'Confirmation text "確認撤回" is required', 400, request);
303+
}
304+
294305
// Check current consent status (or create implicit consent for existing users)
295306
const latestConsent = await env.DB.prepare(`
296307
SELECT consent_status, withdrawn_at

workers/src/handlers/mcp/oauth-authorize.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export async function handleMcpAuthorize(request: Request, env: Env): Promise<Re
125125

126126
const googleAuthUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
127127
googleAuthUrl.searchParams.set('client_id', env.GOOGLE_CLIENT_ID);
128-
googleAuthUrl.searchParams.set('redirect_uri', `${env.WORKER_URL}/mcp/callback`);
128+
googleAuthUrl.searchParams.set('redirect_uri', `${env.CUSTOM_DOMAIN || env.WORKER_URL}/mcp/callback`);
129129
googleAuthUrl.searchParams.set('response_type', 'code');
130130
googleAuthUrl.searchParams.set('scope', 'openid email profile');
131131
googleAuthUrl.searchParams.set('state', googleState);
@@ -187,7 +187,7 @@ export async function handleMcpCallback(request: Request, env: Env, ctx: Executi
187187
code: googleCode,
188188
client_id: env.GOOGLE_CLIENT_ID,
189189
client_secret: env.GOOGLE_CLIENT_SECRET,
190-
redirect_uri: `${env.WORKER_URL}/mcp/callback`,
190+
redirect_uri: `${env.CUSTOM_DOMAIN || env.WORKER_URL}/mcp/callback`,
191191
grant_type: 'authorization_code',
192192
code_verifier: state.google_code_verifier,
193193
}),

workers/src/handlers/mcp/oauth-register.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@ import { anonymizeIP } from '../../utils/audit';
44
function isValidRedirectUri(uri: string): boolean {
55
try {
66
const parsed = new URL(uri);
7-
if (parsed.protocol === 'https:') return true;
8-
if (parsed.protocol === 'http:' && parsed.hostname === 'localhost') return true;
7+
// Loopback: localhost or 127.0.0.1 (any port, per RFC 8252)
8+
if (parsed.protocol === 'http:' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1')) return true;
9+
// Known MCP platforms (HTTPS only)
10+
const allowedHosts = ['claude.ai'];
11+
if (parsed.protocol === 'https:' && allowedHosts.includes(parsed.hostname)) return true;
12+
// Known MCP client custom schemes
13+
if (parsed.protocol === 'cursor:' && parsed.hostname === 'anysphere.cursor-mcp') return true;
914
return false;
1015
} catch {
1116
return false;

workers/src/handlers/oauth-init.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import { generateOAuthState, storeOAuthState } from '../utils/oauth-state';
1212
import { generateOAuthNonce, storeOAuthNonce } from '../utils/oauth-nonce';
1313
import { generateCodeVerifier, generateCodeChallenge } from '../utils/pkce';
1414
import { errorResponse } from '../utils/response';
15+
import { getClientIP } from '../utils/ip';
16+
17+
const OAUTH_INIT_RL_WINDOW = 60_000; // 60s
18+
const OAUTH_INIT_RL_LIMIT = 10;
1519

1620
/**
1721
* Detect if User-Agent is a WebView/In-App Browser
@@ -38,6 +42,22 @@ export async function handleOAuthInit(
3842
env: Env
3943
): Promise<Response> {
4044
try {
45+
// Rate limit per IP (10 requests / 60s)
46+
const ip = getClientIP(request);
47+
try {
48+
const doId = env.RATE_LIMITER.idFromName(`oauth_init:${ip}`);
49+
const stub = env.RATE_LIMITER.get(doId);
50+
const rl = await (stub as any).checkAndIncrement('oauth_init', ip, OAUTH_INIT_RL_WINDOW, OAUTH_INIT_RL_LIMIT);
51+
if (!rl.allowed) {
52+
return new Response(JSON.stringify({ error: 'rate_limit_exceeded' }), {
53+
status: 429,
54+
headers: { 'Content-Type': 'application/json', 'Retry-After': String(Math.ceil((rl.retryAfter || 60000) / 1000)) }
55+
});
56+
}
57+
} catch (e) {
58+
console.error('[OAuth init rate limit error]', e);
59+
}
60+
4161
// Check for WebView/In-App Browser (Google OAuth Policy)
4262
const userAgent = request.headers.get('User-Agent') || '';
4363
if (isWebView(userAgent)) {

0 commit comments

Comments
 (0)