fix(mcp): validate/normalize RFC 8707 resource at issuance; store canonical aud

leo review 最後一條:簽發端沒驗證/正規化 resource → 失敗劇本「OAuth 全程成功但每次打 /mcp 401 aud
mismatch」(尾斜線/canonical 變體,錯誤離根因最遠最難 debug)。改在簽發端 fail fast:

- metadata.ts 加 normalizeResource(scheme/host 小寫、去預設 port、path 去尾斜線,與 resourceUri
  canonical 一致)+ resourceMatches。
- /authorize(GET+POST):帶 resource 且正規化後 != canonical → redirect 帶 error=invalid_target
  (redirect_uri 已驗過才 redirect);一律把 canonical resource 存進 code(不存 client 原樣值)。
- /token:帶 resource 且正規化後 != canonical → 400 invalid_target;aud 一律存 canonical
  resourceUri(origin) → 與 partner-auth 嚴格比對 at.aud===resourceUri(origin) 恆一致。

裁決:尾斜線/大小寫等「正規化後等價」的 resource → 接受(存 canonical aud,/mcp 必過),非拒絕——
否則 claude.ai 真送變體會永久授權失敗連不上(把 401 問題換位重現)。只有正規化後真正不同的
resource(別 host/path)才 fail-fast 拒。詳見 OAUTH.md §2。

測試:normalizeResource/resourceMatches 單元 + 尾斜線變體→正常發碼且 aud canonical、別 host→
/authorize redirect invalid_target 不發碼、/token 別 host→400 invalid_target。
mcp vitest 52/52、tsc exit 0。

Refs #15

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
This commit is contained in:
Claude
2026-07-07 05:38:35 +00:00
parent 92cfb9c59f
commit 5fa3b79a4c
4 changed files with 205 additions and 4 deletions
+14 -1
View File
@@ -32,7 +32,16 @@ PKCE,但**走到發碼那步需要 owner secret**,而 secret 只在 CF Secre
- **redirect_uri 白名單**DCR 無狀態,見 §5):預設只允許 `claude.ai``claude.com``anthropic.com` - **redirect_uri 白名單**DCR 無狀態,見 §5):預設只允許 `claude.ai``claude.com``anthropic.com`
(含子網域)+ `localhost``http` 僅限本機。擋 open-redirect/釣魚把 code 送去攻擊者。可用 (含子網域)+ `localhost``http` 僅限本機。擋 open-redirect/釣魚把 code 送去攻擊者。可用
`MCP_ALLOWED_REDIRECT_HOSTS` 調整。 `MCP_ALLOWED_REDIRECT_HOSTS` 調整。
- **token audience 綁定(RFC 8707**codetoken 記 `resource``aud` = 本 MCP server 的 canonical URI。 - **token audience 綁定 + 簽發端驗證RFC 8707**
- `/authorize``/token` 收到 client 的 `resource` 參數時,**正規化後**(scheme/host 小寫、去預設 port、
path 去尾斜線;與 `metadata.ts resourceUri` 產出的 canonical 一致)必須 == 本 server canonical
`resourceUri(origin)`。**不符則簽發端 fail fast**`/authorize` redirect 帶 `error=invalid_target`
redirect_uri 已驗過才 redirect,否則 400)、`/token` 回 400 `invalid_target`
- **存進 `aud` 的一律是 canonical `resourceUri(origin)`**(不存 client 原樣值)→ 與 partner-auth 的嚴格比對
`at.aud === resourceUri(origin)` 恆一致。
- **為何要正規化而非純字串拒**:claude.ai 送 `…/mcp/`(尾斜線)或大小寫變體是等價 canonical → 正規化後接受、
存 canonical aud → `/mcp` 必過。若對等價形也拒,claude.ai 真送變體會**永久授權失敗連不上**(把「OAuth 成功
但 /mcp 401」的問題換位重現)。只有正規化後**真正不同**的 resource(別的 host/path)才 fail-fast 拒。
- **KV key 用 SHA-256 hash**codetoken 不以明碼當 key,KV list 也拿不到可用憑證(比照 - **KV key 用 SHA-256 hash**codetoken 不以明碼當 key,KV list 也拿不到可用憑證(比照
`wasi-shim.ts` 只寫短效 oauth2 cache 的精神)。 `wasi-shim.ts` 只寫短效 oauth2 cache 的精神)。
- **owner secretstatic token 用常數時間比對**:防 timing attack。 - **owner secretstatic token 用常數時間比對**:防 timing attack。
@@ -107,6 +116,10 @@ metadata 以「當前請求 origin」動態生成 → 同一份碼在 `mcp.arcru
- PKCE S256 驗證(正確 / 拒 plain / 拒缺省 method / verifier 長度邊界 / 竄改);已知 SHA-256 向量、RFC 7636 附錄範例。 - PKCE S256 驗證(正確 / 拒 plain / 拒缺省 method / verifier 長度邊界 / 竄改);已知 SHA-256 向量、RFC 7636 附錄範例。
- 短效 KV storeauthorization code 一次性(consume 後失效,防重放)、access token 存取 + `exp` 過期、key 為 hash。 - 短效 KV storeauthorization code 一次性(consume 後失效,防重放)、access token 存取 + `exp` 過期、key 為 hash。
- metadataorigin 推導、Protected Resource / AS Metadata 必要欄位、`WWW-Authenticate` 格式。 - metadataorigin 推導、Protected Resource / AS Metadata 必要欄位、`WWW-Authenticate` 格式。
- **RFC 8707 resource 正規化/簽發端驗證**`normalizeResource`/`resourceMatches`(尾斜線/大小寫/預設 port
等價、別 host/path 不等價);`/authorize` 尾斜線變體→**正常發碼**且 token aud 為 canonical、
別 host 的 resource → GET/POST `/authorize` redirect `error=invalid_target` 不發碼、`/token` 帶別 host
resource → 400 `invalid_target`
- consent XSS escape(惡意 `state` 不注入)。 - consent XSS escape(惡意 `state` 不注入)。
- 完整流程整合(Hono `app.request`):well-known 動態 origin、DCR 回 client_id 無 secret、redirect_uri - 完整流程整合(Hono `app.request`):well-known 動態 origin、DCR 回 client_id 無 secret、redirect_uri
白名單擋非法 host、GET `/authorize` 要 PKCE、`MCP_OWNER_SECRET` 未設→503、**正確祕密+正確 verifier→ 白名單擋非法 host、GET `/authorize` 要 PKCE、`MCP_OWNER_SECRET` 未設→503、**正確祕密+正確 verifier→
+32
View File
@@ -12,6 +12,38 @@ export function resourceUri(origin: string): string {
return `${origin}/mcp`; return `${origin}/mcp`;
} }
/**
* 把 client 傳的 `resource`RFC 8707)正規化成 canonical 形式,好和 `resourceUri(origin)` 嚴格比對。
* 正規化規則與 originOf/resourceUri 產出的 canonical 一致:
* - scheme + host 小寫(URL 也自動去掉預設 port,如 https 的 :443
* - path 去尾斜線(`/mcp/` → `/mcp`;根 `/` 保留)
* - 丟棄 query / fragmentresource 不該帶)
* 非法 URL → null。
*/
export function normalizeResource(value: string): string | null {
let u: URL;
try {
u = new URL(value);
} catch {
return null;
}
const scheme = u.protocol.toLowerCase(); // 含結尾冒號,如 "https:"
const host = u.host.toLowerCase(); // 含非預設 port;預設 port 已被 URL 去掉
let path = u.pathname;
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
return `${scheme}//${host}${path}`;
}
/**
* 判斷 client 傳的 `resource` 正規化後是否 == 本 server 的 canonical resource URI。
* 用於 /authorize、/token 的簽發端把關(fail fast),避免尾斜線/大小寫變體造成
* 「OAuth 成功但每次打 /mcp 都 401 aud mismatch」的最難 debug 劇本。
*/
export function resourceMatches(resourceParam: string, origin: string): boolean {
const n = normalizeResource(resourceParam);
return n !== null && n === resourceUri(origin);
}
/** RFC 9728 Protected Resource Metadata。 */ /** RFC 9728 Protected Resource Metadata。 */
export function protectedResourceMetadata(origin: string) { export function protectedResourceMetadata(origin: string) {
return { return {
+35 -3
View File
@@ -18,6 +18,7 @@ import {
import { import {
originOf, originOf,
resourceUri, resourceUri,
resourceMatches,
protectedResourceMetadata, protectedResourceMetadata,
authorizationServerMetadata, authorizationServerMetadata,
} from "./metadata.js"; } from "./metadata.js";
@@ -170,6 +171,19 @@ export function registerOAuthRoutes<
// redirect_uri 本身可疑 → 絕不 redirect(防 open redirect),直接顯示錯誤。 // redirect_uri 本身可疑 → 絕不 redirect(防 open redirect),直接顯示錯誤。
return c.text("invalid_request: redirect_uri missing or not allowed", 400); return c.text("invalid_request: redirect_uri missing or not allowed", 400);
} }
const canonicalResource = resourceUri(originOf(c.req.url));
// RFC 8707client 傳了 resource 就必須正規化後 == 本 server canonical,否則簽發端 fail fast。
// redirect_uri 已驗過 → 用 OAuth 錯誤 redirect 帶 error=invalid_target(比顯示 400 更符規範)。
if (q.resource && !resourceMatches(q.resource, originOf(c.req.url))) {
return c.redirect(
redirectWith(q.redirect_uri, {
error: "invalid_target",
error_description: "resource does not match this MCP server",
...(q.state ? { state: q.state } : {}),
}),
302,
);
}
if (!c.env.MCP_OWNER_SECRET) { if (!c.env.MCP_OWNER_SECRET) {
return c.text("server_error: MCP_OWNER_SECRET not configured", 503); return c.text("server_error: MCP_OWNER_SECRET not configured", 503);
} }
@@ -180,7 +194,7 @@ export function registerOAuthRoutes<
code_challenge: q.code_challenge, code_challenge: q.code_challenge,
code_challenge_method: "S256", code_challenge_method: "S256",
scope: q.scope ?? "mcp", scope: q.scope ?? "mcp",
resource: q.resource ?? resourceUri(originOf(c.req.url)), resource: canonicalResource, // 一律存 canonical,不存 client 原樣值
}; };
return c.html(consentPage(params)); return c.html(consentPage(params));
}); });
@@ -196,6 +210,18 @@ export function registerOAuthRoutes<
if (p.code_challenge_method !== "S256" || !p.code_challenge) { if (p.code_challenge_method !== "S256" || !p.code_challenge) {
return c.text("invalid_request: PKCE S256 required", 400); return c.text("invalid_request: PKCE S256 required", 400);
} }
const canonicalResource = resourceUri(originOf(c.req.url));
// RFC 8707resource(隱藏欄位帶回,仍可能被竄改)→ 正規化後須 == canonical,否則 redirect 帶 invalid_target。
if (p.resource && !resourceMatches(p.resource, originOf(c.req.url))) {
return c.redirect(
redirectWith(redirectUri, {
error: "invalid_target",
error_description: "resource does not match this MCP server",
...(p.state ? { state: p.state } : {}),
}),
302,
);
}
if (!c.env.MCP_OWNER_SECRET) { if (!c.env.MCP_OWNER_SECRET) {
return c.text("server_error: MCP_OWNER_SECRET not configured", 503); return c.text("server_error: MCP_OWNER_SECRET not configured", 503);
} }
@@ -206,7 +232,7 @@ export function registerOAuthRoutes<
code_challenge: p.code_challenge, code_challenge: p.code_challenge,
code_challenge_method: "S256", code_challenge_method: "S256",
scope: p.scope ?? "mcp", scope: p.scope ?? "mcp",
resource: p.resource ?? resourceUri(originOf(c.req.url)), resource: canonicalResource, // 一律存 canonical,不存 client 原樣值
}; };
// ★ owner 祕密把關:錯誤不發碼、重顯同意頁。這是「只知 URL 的人進不來」的唯一閘。 // ★ owner 祕密把關:錯誤不發碼、重顯同意頁。這是「只知 URL 的人進不來」的唯一閘。
const supplied = p.owner_secret ?? ""; const supplied = p.owner_secret ?? "";
@@ -245,6 +271,10 @@ export function registerOAuthRoutes<
if (!p.code || !p.code_verifier || !p.redirect_uri) { if (!p.code || !p.code_verifier || !p.redirect_uri) {
return err("invalid_request", "code, code_verifier and redirect_uri are required"); return err("invalid_request", "code, code_verifier and redirect_uri are required");
} }
// RFC 8707token request 帶 resource 就得正規化後 == canonical,否則簽發端拒(400 invalid_target)。
if (p.resource && !resourceMatches(p.resource, originOf(c.req.url))) {
return err("invalid_target", "resource does not match this MCP server");
}
if (!c.env.OAUTH_KV) { if (!c.env.OAUTH_KV) {
return err("server_error", "OAUTH_KV not configured", 503); return err("server_error", "OAUTH_KV not configured", 503);
} }
@@ -272,7 +302,9 @@ export function registerOAuthRoutes<
namespace: data.namespace, namespace: data.namespace,
client_id: data.client_id, client_id: data.client_id,
scope: data.scope, scope: data.scope,
aud: data.resource, // RFC 8707綁定受眾 = 本 MCP server // RFC 8707aud 一律用「本 server canonical resource URI」(非 client 原樣值)。
// authorize 已只存 canonical,這裡再以當前 origin 重算一次確保與 partner-auth 嚴格比對一致。
aud: resourceUri(originOf(c.req.url)),
exp: Math.floor(Date.now() / 1000) + ttl, exp: Math.floor(Date.now() / 1000) + ttl,
}, },
ttl, ttl,
+124
View File
@@ -16,6 +16,8 @@ import {
import { import {
originOf, originOf,
resourceUri, resourceUri,
normalizeResource,
resourceMatches,
protectedResourceMetadata, protectedResourceMetadata,
authorizationServerMetadata, authorizationServerMetadata,
wwwAuthenticateHeader, wwwAuthenticateHeader,
@@ -176,6 +178,22 @@ describe("oauth/metadata", () => {
'Bearer resource_metadata="https://mcp.arcrun.dev/.well-known/oauth-protected-resource"', 'Bearer resource_metadata="https://mcp.arcrun.dev/.well-known/oauth-protected-resource"',
); );
}); });
it("normalizeResource:尾斜線 / 大小寫 scheme+host / 預設 port 都正規化成 canonical", () => {
const canon = "https://mcp.arcrun.dev/mcp";
expect(normalizeResource("https://mcp.arcrun.dev/mcp")).toBe(canon);
expect(normalizeResource("https://mcp.arcrun.dev/mcp/")).toBe(canon); // 尾斜線
expect(normalizeResource("HTTPS://Mcp.Arcrun.Dev/mcp")).toBe(canon); // 大小寫 scheme+host
expect(normalizeResource("https://mcp.arcrun.dev:443/mcp")).toBe(canon); // 預設 port
expect(normalizeResource("not a url")).toBeNull();
});
it("resourceMatchescanonical / 尾斜線變體都 true;別的 host/path false", () => {
const origin = "https://mcp.arcrun.dev";
expect(resourceMatches("https://mcp.arcrun.dev/mcp", origin)).toBe(true);
expect(resourceMatches("https://mcp.arcrun.dev/mcp/", origin)).toBe(true);
expect(resourceMatches("https://other.example.com/mcp", origin)).toBe(false);
expect(resourceMatches("https://mcp.arcrun.dev/other", origin)).toBe(false);
expect(resourceMatches("garbage", origin)).toBe(false);
});
}); });
// ── consent escaping(XSS)──────────────────────────────────────────────────── // ── consent escaping(XSS)────────────────────────────────────────────────────
@@ -409,6 +427,112 @@ describe("oauth flow (整合)", () => {
}); });
}); });
// ── RFC 8707 resource 簽發端驗證/正規化 ─────────────────────────────────────────
// 決策:resource 正規化後 == canonical → 接受(尾斜線/大小寫/預設 port 皆等價,aud 一律存 canonical
// /mcp 嚴格比對必過);正規化後 != canonical(別的 host/path)→ 簽發端 fail-fast 拒 invalid_target。
// 若把尾斜線也拒,claude.ai 真送尾斜線變體會永久連不上(把 401 問題換位重現),故等價形接受才對。
describe("oauth resourceRFC 8707)簽發端把關", () => {
const redirect = "https://claude.ai/cb";
async function postAuthorize(app: ReturnType<typeof buildApp>, resource: string, challenge: string) {
return app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: redirect,
state: "st",
code_challenge: challenge,
code_challenge_method: "S256",
resource,
owner_secret: "s3cr3t-owner",
}).toString(),
redirect: "manual",
});
}
it("尾斜線變體(等價 canonical)→ 正常發碼,且換出 token 的 aud 為 canonical", async () => {
const env = baseEnv();
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
const r = await postAuthorize(app, "https://mcp.arcrun.dev/mcp/", challenge);
expect(r.status).toBe(302);
const loc = new URL(r.headers.get("location")!);
expect(loc.searchParams.get("error")).toBeNull(); // 未被拒
const code = loc.searchParams.get("code")!;
expect(code).toBeTruthy();
const tokRes = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: verifier,
redirect_uri: redirect,
resource: "https://mcp.arcrun.dev/mcp/", // token 端也帶尾斜線 → 正規化後仍通過
}).toString(),
});
expect(tokRes.status).toBe(200);
const at = await getAccessToken(env.OAUTH_KV!, (await tokRes.json()).access_token);
expect(at?.aud).toBe("https://mcp.arcrun.dev/mcp"); // 存的是 canonical,與 partner-auth 嚴格比對一致
});
it("正確 canonical resource → 正常發碼", async () => {
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
const r = await postAuthorize(app, "https://mcp.arcrun.dev/mcp", challenge);
expect(r.status).toBe(302);
expect(new URL(r.headers.get("location")!).searchParams.get("code")).toBeTruthy();
});
it("GET /authorize:別的 host 的 resource → redirect 帶 error=invalid_target(不顯示同意頁)", async () => {
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
const r = await app.req(
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(redirect)}` +
`&code_challenge=${challenge}&code_challenge_method=S256&state=st` +
`&resource=${encodeURIComponent("https://evil.example.com/mcp")}`,
);
expect(r.status).toBe(302);
const loc = new URL(r.headers.get("location")!);
expect(loc.searchParams.get("error")).toBe("invalid_target");
expect(loc.searchParams.get("state")).toBe("st");
expect(loc.searchParams.get("code")).toBeNull();
});
it("POST /authorize:別的 host 的 resource → redirect invalid_target,不發碼", async () => {
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
const r = await postAuthorize(app, "https://evil.example.com/mcp", challenge);
expect(r.status).toBe(302);
const loc = new URL(r.headers.get("location")!);
expect(loc.searchParams.get("error")).toBe("invalid_target");
expect(loc.searchParams.get("code")).toBeNull();
});
it("POST /token:別的 host 的 resource → 400 invalid_target", async () => {
const env = baseEnv();
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
// 先正常拿一個 codeauthorize 不帶 resource → 存 canonical
const authRes = await postAuthorize(app, "https://mcp.arcrun.dev/mcp", challenge);
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
const tokRes = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: verifier,
redirect_uri: redirect,
resource: "https://evil.example.com/mcp", // token 端 resource 不符 → 拒
}).toString(),
});
expect(tokRes.status).toBe(400);
expect((await tokRes.json()).error).toBe("invalid_target");
});
});
// ── 防 drift:對 OAUTH_KV 的每一次 put 都必須帶 expirationTtl(守儲存鐵律)────────── // ── 防 drift:對 OAUTH_KV 的每一次 put 都必須帶 expirationTtl(守儲存鐵律)──────────
// spy KV 記錄所有 put(key,value,opts);跑完整流程後斷言沒有任何一次「無 TTL」的 put, // spy KV 記錄所有 put(key,value,opts);跑完整流程後斷言沒有任何一次「無 TTL」的 put,
// 防未來有人往這顆短效 KV 塞長效資料(access_token / code 以外的東西)。 // 防未來有人往這顆短效 KV 塞長效資料(access_token / code 以外的東西)。