fix(mcp): address PR #15 review — aud validation, OAUTH_KV auto-inject, TTL/sunset docs, drift test
leo review 5 條逐條處理: 1. RFC 8707 aud 驗證(真缺口):partner-auth OAuth 路徑補「at.aud === resourceUri(originOf(url))」, 不符回 401 invalid_token(防別的 arcrun-mcp 部署簽的 token passthrough)。加 aud 不符→401 測試。 2. deploy.ts injectWranglerConfig 涵蓋 OAUTH_KV(在 PR 內補):OAUTH_KV 納入 REQUIRED_KV_NAMESPACES → acr init/update 自動建 namespace + 注入用戶帳號真 id(比照 SUBMISSIONS_KV 家族)。wrangler.toml 註解 更新(CLI 路徑自動、手動直推才需手建)。注入 regex 已驗證命中。 3. MCP_TOKEN_TTL 預設維持 30 天(leo 拍板不改):OAUTH.md 明寫為有意取捨(無 refresh token → 到期重走 OAuth=再輸 owner secret),MCP_TOKEN_TTL 可調、7 天為更保守選項。per-owner 可調另開 issue #19(非阻塞)。 4. ALLOW_PLAINTEXT_NAMESPACE 逃生門標 SUNSET(code + wrangler.toml + OAUTH.md),開 issue #18 追蹤 「遷移完成後移除整段 code path + Env 欄位」。 5. 防 drift 測試:spy KV 攔所有 put,斷言對 OAUTH_KV 的每一次 put 都帶 expirationTtl>0(完整流程 + store 層兩道),防未來往這顆短效 KV 塞長效資料。 驗證:mcp tsc exit 0、vitest 45/45(+aud +drift×2);cli tsc exit 0。 Refs #15 #18 #19 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
This commit is contained in:
@@ -73,6 +73,10 @@ const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'uncle6me-web/Arcrun';
|
||||
* SUBMISSIONS_KV:registry worker 用(component 投稿)。漏建會讓 registry deploy 失敗 →
|
||||
* 壓測 §2.6/#11「20/21」根因(registry/wrangler.toml 綁 SUBMISSIONS_KV,但注入清單沒有它,
|
||||
* 殘留官方舊 id → wrangler deploy 因 KV 不存在而失敗)。補進來後回到 21/21。
|
||||
* OAUTH_KV:arcrun-mcp worker 用(OAuth 2.1 server 的短效 authorization code + access token,
|
||||
* 帶 TTL、key 用 SHA-256 hash)。mcp/wrangler.toml 綁 OAUTH_KV,占位 id 由 injectWranglerConfig
|
||||
* 換成用戶帳號的真 id(比照上面同一套 title→binding 注入)。漏建 → mcp deploy 失敗(同 SUBMISSIONS_KV
|
||||
* 家族)。見 mcp/OAUTH.md §4/§7。
|
||||
*/
|
||||
export const REQUIRED_KV_NAMESPACES = [
|
||||
'WEBHOOKS',
|
||||
@@ -83,6 +87,7 @@ export const REQUIRED_KV_NAMESPACES = [
|
||||
'ANALYTICS_KV',
|
||||
'EXEC_CONTEXT',
|
||||
'SUBMISSIONS_KV',
|
||||
'OAUTH_KV',
|
||||
] as const;
|
||||
|
||||
/** 部署後要提示用戶手動 `wrangler secret put ENCRYPTION_KEY` 的 Worker。*/
|
||||
|
||||
+28
-10
@@ -75,7 +75,7 @@ metadata 以「當前請求 origin」動態生成 → 同一份碼在 `mcp.arcru
|
||||
2. **`MCP_STATIC_TOKEN`(真祕密,CF Secret)** → 解成 `MCP_OWNER_NAMESPACE`。**本機 CLI / GUI /
|
||||
本機 Claude Code 的相容路徑**——用「真祕密 token」取代舊「明碼 namespace」,owner 可掌控(CF Secrets)。
|
||||
3. **官方 SaaS(`MULTI_TENANT` 未設/`true`)** → KBDB partner-key 驗證,**行為完全不變**。
|
||||
4. **【預設關】`ALLOW_PLAINTEXT_NAMESPACE="true"`** → 恢復舊明碼路徑。**僅遷移期**,設了等於重開漏洞,正式勿用。
|
||||
4. **【預設關,SUNSET】`ALLOW_PLAINTEXT_NAMESPACE="true"`** → 恢復舊明碼路徑。**僅遷移期**,設了等於重開漏洞,正式勿用。
|
||||
5. 皆不符 → 401 + `WWW-Authenticate`。
|
||||
|
||||
**明碼 namespace 當 bearer 的舊路徑已從預設移除**(步驟 5 直接 401)。之所以保留步驟 2/4:
|
||||
@@ -84,13 +84,26 @@ metadata 以「當前請求 origin」動態生成 → 同一份碼在 `mcp.arcru
|
||||
明碼 namespace 屬 CLI 側後續(本 PR 未動 CLI,於報告標為待辦)。
|
||||
- `ALLOW_PLAINTEXT_NAMESPACE` 只是遷移期的明確 opt-in 逃生門,預設關 = 預設安全。
|
||||
|
||||
**逃生門有退場(SUNSET)**:`ALLOW_PLAINTEXT_NAMESPACE` 只是遷移期暫時相容,**驗收完即刪整段 code path
|
||||
+ `Env` 欄位**。移除追蹤:**Gitea issue #18**(https://git.uncle6.me/Leo/Arcrun/issues/18)。code 中該分支已標
|
||||
`SUNSET` 註記。
|
||||
|
||||
### access_token TTL 是有意取捨(無 refresh token)
|
||||
|
||||
**為何不做 refresh token**:refresh token 需長效持久化,依鐵律得進 CF Secrets/KBDB 而非 KV,成本與
|
||||
面積都大。改採「較長 TTL 的 access_token(預設 30 天)+ 到期重走 OAuth(owner 重輸祕密)」,兼顧安全
|
||||
(週期性重認證)與簡潔(無長效機密落地)。TTL 由 `MCP_TOKEN_TTL` 調。
|
||||
面積都大。**有意取捨**=改採「較長 TTL 的 access_token + 到期重走 OAuth(owner 重輸一次 owner secret)」,
|
||||
兼顧安全(週期性重認證)與簡潔(無長效機密落地)。
|
||||
|
||||
- **預設 `MCP_TOKEN_TTL` = 2592000 秒(30 天)**——本 PR 維持此預設(leo 拍板不改)。
|
||||
- **可調**:`[vars]` 改 `MCP_TOKEN_TTL` 即可;**7 天(604800)為更保守選項**(縮短 = 更頻繁重認證 = 更安全但 UX 略煩)。
|
||||
- **到期行為**:KV TTL 到 → token 自動失效 → `/mcp` 回 401 + `WWW-Authenticate` → claude.ai 重走 OAuth
|
||||
(再輸一次 owner secret)。無 refresh token 故無長效機密落地。
|
||||
- **後續(per-owner 可調,非本 PR)**:TTL 風險偏好交用戶決定——console 設定頁 → 存 KBDB → `/token` 發 token
|
||||
時讀 per-owner 覆蓋、回退 30 天。追蹤:**Gitea issue #19**(https://git.uncle6.me/Leo/Arcrun/issues/19)。
|
||||
|
||||
## 6. 測試涵蓋
|
||||
|
||||
`mcp/tests/unit/oauth.test.ts`(22):
|
||||
`mcp/tests/unit/oauth.test.ts`:
|
||||
- PKCE S256 驗證(正確 / 拒 plain / 拒缺省 method / verifier 長度邊界 / 竄改);已知 SHA-256 向量、RFC 7636 附錄範例。
|
||||
- 短效 KV store:authorization code 一次性(consume 後失效,防重放)、access token 存取 + `exp` 過期、key 為 hash。
|
||||
- metadata:origin 推導、Protected Resource / AS Metadata 必要欄位、`WWW-Authenticate` 格式。
|
||||
@@ -99,22 +112,27 @@ metadata 以「當前請求 origin」動態生成 → 同一份碼在 `mcp.arcru
|
||||
白名單擋非法 host、GET `/authorize` 要 PKCE、`MCP_OWNER_SECRET` 未設→503、**正確祕密+正確 verifier→
|
||||
access_token**、**錯誤祕密→401 不發 code**、錯誤 verifier→invalid_grant、重用 code→invalid_grant、
|
||||
`OAUTH_KV` 未設→503。
|
||||
- **防 drift**:spy KV 攔所有 `put`,斷言對 `OAUTH_KV` 的**每一次 put 都帶 `expirationTtl`(> 0)**——
|
||||
防未來有人往這顆 KV 塞長效資料(守儲存鐵律)。
|
||||
|
||||
`mcp/tests/unit/partner-auth.test.ts`(10,改測真實 middleware):
|
||||
`mcp/tests/unit/partner-auth.test.ts`(改測真實 middleware):
|
||||
- 無/壞 Authorization → 401 + `WWW-Authenticate`(RFC 9728)。
|
||||
- OAuth token → 解出 namespace;未知 token(明碼)在 self-hosted → **401(洞已補)**。
|
||||
- **RFC 8707 aud 驗證**:token `aud` 不等於本次請求 origin 算出的 canonical resource URI → **401 `invalid_token`**。
|
||||
- `MCP_STATIC_TOKEN` 相容路徑通過 / 明碼被擋。
|
||||
- 官方 SaaS partner-key(mock KBDB)行為不變。
|
||||
- `ALLOW_PLAINTEXT_NAMESPACE` 逃生門開/關。
|
||||
|
||||
全部 42 tests 綠;`tsc --noEmit` exit 0;`wrangler deploy --dry-run` 打包過、`OAUTH_KV` binding 正確識別。
|
||||
全部測試綠;`tsc --noEmit` exit 0;`wrangler deploy --dry-run` 打包過、`OAUTH_KV` binding 正確識別。
|
||||
|
||||
## 7. leo 部署前要做什麼(不在本 PR 內,本 PR 不部署)
|
||||
|
||||
1. **建 KV namespace 並填 id**:`wrangler kv namespace create OAUTH_MCP` → 把 id 貼進
|
||||
`mcp/wrangler.toml` 的 `[[kv_namespaces]] OAUTH_KV`(目前是 `REPLACE_WITH_REAL_KV_ID` 佔位)。
|
||||
> self-hosted 自動注入(`deploy.ts injectWranglerConfig`)**尚未涵蓋此新 binding** → 需手動填,或
|
||||
> 補 injectWranglerConfig 加一條(跨 CLI 的後續,見報告待辦)。
|
||||
1. **建 KV namespace + 填 id**(依部署路徑):
|
||||
- **CLI 路徑(`acr init` / `acr update`)→ 已自動化**:本 PR 把 `OAUTH_KV` 納入 `deploy.ts`
|
||||
`REQUIRED_KV_NAMESPACES` → init/update 自動建 namespace(冪等)+ `injectWranglerConfig` 把
|
||||
`REPLACE_WITH_REAL_KV_ID` 換成用戶帳號真 id。零手動。
|
||||
- **手動直推(leo21c wrangler deploy,mistakes #23 codeload 陷阱下走的路徑)→ 需手動**:
|
||||
`wrangler kv namespace create OAUTH_MCP` → 把 id 貼進 `mcp/wrangler.toml` 的 `[[kv_namespaces]] OAUTH_KV`。
|
||||
2. **設 owner secret(CF Secrets)**:`wrangler secret put MCP_OWNER_SECRET`(輸入只有你知道的強祕密)。
|
||||
3.(選配)**設本機相容 static token**:`wrangler secret put MCP_STATIC_TOKEN`,並把本機 `.mcp.json` 的
|
||||
`Authorization: Bearer <此值>`(取代舊明碼 namespace)。
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Context, Next } from "hono";
|
||||
import { Env } from "../types.js";
|
||||
import { getAccessToken } from "../oauth/store.js";
|
||||
import { constantTimeEqual } from "../oauth/crypto.js";
|
||||
import { originOf, wwwAuthenticateHeader } from "../oauth/metadata.js";
|
||||
import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.js";
|
||||
|
||||
/**
|
||||
* MCP / GUI 端點認證中介層。
|
||||
@@ -41,6 +41,13 @@ export async function partnerAuthMiddleware(
|
||||
if (c.env.OAUTH_KV) {
|
||||
const at = await getAccessToken(c.env.OAUTH_KV, token);
|
||||
if (at) {
|
||||
// RFC 8707 audience 驗證:token 只能用在它被簽發的目標 MCP server。
|
||||
// token 的 aud 是簽發時綁定的 resource;須等於「本次請求 origin 算出的 canonical resource URI」,
|
||||
// 否則拒絕(防別的 arcrun-mcp 部署簽的 token 拿來這裡用 = token passthrough)。
|
||||
const expectedAud = resourceUri(origin);
|
||||
if (at.aud !== expectedAud) {
|
||||
return unauthorized("Access token audience mismatch", "invalid_token");
|
||||
}
|
||||
c.set("org_namespace", at.namespace);
|
||||
c.set("partner_token", at.namespace); // 下游 cypher 用 namespace 當 X-Arcrun-API-Key(與 CLI 同一份身份)
|
||||
await next();
|
||||
@@ -76,7 +83,9 @@ export async function partnerAuthMiddleware(
|
||||
return;
|
||||
}
|
||||
|
||||
// 4)【預設關,不安全】遷移逃生門:恢復舊明碼 namespace 行為。
|
||||
// 4)【預設關,不安全,SUNSET】遷移逃生門:恢復舊明碼 namespace 行為。
|
||||
// ⚠️ 這是暫時相容路徑,遷移驗收完即刪整段(含 Env.ALLOW_PLAINTEXT_NAMESPACE 欄位)。
|
||||
// 追蹤 issue:見 OAUTH.md §5 / wrangler.toml 註解。留著只為遷移期,別當長期選項。
|
||||
if (c.env.ALLOW_PLAINTEXT_NAMESPACE === "true") {
|
||||
c.set("org_namespace", token);
|
||||
c.set("partner_token", token);
|
||||
|
||||
@@ -408,3 +408,89 @@ describe("oauth flow (整合)", () => {
|
||||
expect(r.status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 防 drift:對 OAUTH_KV 的每一次 put 都必須帶 expirationTtl(守儲存鐵律)──────────
|
||||
// spy KV 記錄所有 put(key,value,opts);跑完整流程後斷言沒有任何一次「無 TTL」的 put,
|
||||
// 防未來有人往這顆短效 KV 塞長效資料(access_token / code 以外的東西)。
|
||||
describe("oauth store drift guard:OAUTH_KV 的 put 一律帶 TTL", () => {
|
||||
function spyKV(): { kv: KVNamespace; puts: Array<{ key: string; opts?: { expirationTtl?: number } }> } {
|
||||
const map = new Map<string, string>();
|
||||
const puts: Array<{ key: string; opts?: { expirationTtl?: number } }> = [];
|
||||
const kv = {
|
||||
async put(key: string, value: string, opts?: { expirationTtl?: number }) {
|
||||
puts.push({ key, opts });
|
||||
map.set(key, value);
|
||||
},
|
||||
async get(key: string) {
|
||||
return map.get(key) ?? null;
|
||||
},
|
||||
async delete(key: string) {
|
||||
map.delete(key);
|
||||
},
|
||||
} as unknown as KVNamespace;
|
||||
return { kv, puts };
|
||||
}
|
||||
|
||||
it("完整 authorize→token 流程中,OAUTH_KV 的每次 put 都有 expirationTtl>0", async () => {
|
||||
const { kv, puts } = spyKV();
|
||||
const env = baseEnv({ OAUTH_KV: kv });
|
||||
const app = buildApp(env);
|
||||
const { verifier, challenge } = await pkcePair();
|
||||
const redirect = "https://claude.ai/cb";
|
||||
|
||||
const authRes = await app.req("/authorize", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: "c1",
|
||||
redirect_uri: redirect,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
owner_secret: "s3cr3t-owner",
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
||||
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,
|
||||
client_id: "c1",
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
// 至少發生了 put(code + token 各一),且每一次都帶 TTL。
|
||||
expect(puts.length).toBeGreaterThanOrEqual(2);
|
||||
for (const p of puts) {
|
||||
expect(p.opts?.expirationTtl, `put ${p.key} 缺 expirationTtl`).toBeTypeOf("number");
|
||||
expect(p.opts!.expirationTtl!).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("直接呼叫 store 層 putAuthCode / putAccessToken 也一律帶 TTL", async () => {
|
||||
const { kv, puts } = spyKV();
|
||||
await putAuthCode(kv, "c", {
|
||||
client_id: "c1",
|
||||
redirect_uri: "https://claude.ai/cb",
|
||||
code_challenge: "cc",
|
||||
code_challenge_method: "S256",
|
||||
scope: "mcp",
|
||||
resource: "https://mcp/mcp",
|
||||
namespace: "leo",
|
||||
});
|
||||
await putAccessToken(
|
||||
kv,
|
||||
"t",
|
||||
{ namespace: "leo", client_id: "c1", scope: "mcp", aud: "https://mcp/mcp", exp: 1 },
|
||||
100,
|
||||
);
|
||||
expect(puts).toHaveLength(2);
|
||||
for (const p of puts) {
|
||||
expect(p.opts?.expirationTtl).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,10 +70,17 @@ describe("partner-auth: 無 / 壞 Authorization → 401 + WWW-Authenticate(RFC
|
||||
describe("partner-auth: OAuth access token 路徑(遠端 claude.ai)", () => {
|
||||
it("有效 access_token → 解出綁定 namespace", async () => {
|
||||
const kv = makeKV();
|
||||
// aud 必須 == 本次請求 origin 的 canonical resource URI(app.request 打 https://mcp.arcrun.dev/mcp)
|
||||
await putAccessToken(
|
||||
kv,
|
||||
"good-token",
|
||||
{ namespace: "leo", client_id: "c1", scope: "mcp", aud: "x", exp: Math.floor(Date.now() / 1000) + 100 },
|
||||
{
|
||||
namespace: "leo",
|
||||
client_id: "c1",
|
||||
scope: "mcp",
|
||||
aud: "https://mcp.arcrun.dev/mcp",
|
||||
exp: Math.floor(Date.now() / 1000) + 100,
|
||||
},
|
||||
100,
|
||||
);
|
||||
const r = await buildApp(baseEnv({ MULTI_TENANT: "false", OAUTH_KV: kv }))("Bearer good-token");
|
||||
@@ -81,6 +88,25 @@ describe("partner-auth: OAuth access token 路徑(遠端 claude.ai)", () =>
|
||||
expect((await r.json()).org_namespace).toBe("leo");
|
||||
});
|
||||
|
||||
it("RFC 8707 aud 不符 → 401 invalid_token(防 token passthrough)", async () => {
|
||||
const kv = makeKV();
|
||||
await putAccessToken(
|
||||
kv,
|
||||
"wrong-aud-token",
|
||||
{
|
||||
namespace: "leo",
|
||||
client_id: "c1",
|
||||
scope: "mcp",
|
||||
aud: "https://other-mcp.example.com/mcp", // 別的部署簽的 token
|
||||
exp: Math.floor(Date.now() / 1000) + 100,
|
||||
},
|
||||
100,
|
||||
);
|
||||
const r = await buildApp(baseEnv({ MULTI_TENANT: "false", OAUTH_KV: kv }))("Bearer wrong-aud-token");
|
||||
expect(r.status).toBe(401);
|
||||
expect(r.headers.get("WWW-Authenticate")).toContain("resource_metadata=");
|
||||
});
|
||||
|
||||
it("未知 token(非 OAuth、非 static)在 self-hosted → 401(明碼 namespace 不再放行)", async () => {
|
||||
const kv = makeKV();
|
||||
const r = await buildApp(baseEnv({ MULTI_TENANT: "false", OAUTH_KV: kv }))("Bearer leo");
|
||||
|
||||
+9
-6
@@ -33,13 +33,15 @@ service = "arcrun-kbdb"
|
||||
|
||||
# ── OAuth 2.1 server(claude.ai 遠端 connector 安全登入,見 mcp/OAUTH.md)──────────────
|
||||
# 短效認證儲存(authorization code + access token,皆帶 TTL、key 用 SHA-256 hash)。
|
||||
# ⚠️ 部署前 leo/mira 需先建立此 KV namespace 並填入真實 id:
|
||||
# wrangler kv namespace create OAUTH_MCP
|
||||
# 把回傳的 id 貼到下面(self-hosted 自動注入路徑尚未涵蓋此 binding → 需手動或補 deploy.ts injectWranglerConfig)。
|
||||
# id 是占位:
|
||||
# • CLI 路徑(acr init / acr update)→ deploy.ts 已把 OAUTH_KV 納入 REQUIRED_KV_NAMESPACES,
|
||||
# 自動建 namespace + injectWranglerConfig 換成用戶帳號真 id(零手動)。
|
||||
# • 手動直推(leo21c wrangler deploy,mistakes #23 codeload 陷阱下的路徑)→ 需自己先建並填 id:
|
||||
# wrangler kv namespace create OAUTH_MCP → 把 id 貼到下面。
|
||||
# 未設此 binding → OAuth /authorize、/token 回 503(誠實拒絕,不假綠),既有 MCP 路徑不受影響。
|
||||
[[kv_namespaces]]
|
||||
binding = "OAUTH_KV"
|
||||
id = "REPLACE_WITH_REAL_KV_ID" # ← 部署前必填(wrangler kv namespace create OAUTH_MCP)
|
||||
id = "REPLACE_WITH_REAL_KV_ID" # CLI 部署會自動換;手動直推才需填(wrangler kv namespace create OAUTH_MCP)
|
||||
|
||||
# ── OAuth 相關機密與設定 ───────────────────────────────────────────────────────────
|
||||
# 機密走 CF Secrets(wrangler secret put,不進 toml、非 KV、非明碼 var):
|
||||
@@ -47,9 +49,10 @@ id = "REPLACE_WITH_REAL_KV_ID" # ← 部署前必填(wrangler kv namespace cr
|
||||
# wrangler secret put MCP_STATIC_TOKEN # (選配)本機 CLI/GUI/Claude Code 相容用的真祕密 token
|
||||
# 非機密設定可放 [vars](下方 [vars] table):
|
||||
# MCP_OWNER_NAMESPACE access_token 綁定的資料分區,預設 "leo"
|
||||
# MCP_TOKEN_TTL access_token 存活秒數,預設 2592000(30 天)
|
||||
# MCP_TOKEN_TTL access_token 存活秒數,預設 2592000(30 天,有意取捨見 OAUTH.md §5;7 天為更保守選項)
|
||||
# MCP_ALLOWED_REDIRECT_HOSTS 逗號分隔 redirect host 白名單,預設 claude.ai/claude.com/anthropic.com+localhost
|
||||
# ALLOW_PLAINTEXT_NAMESPACE 【不安全】="true" 恢復舊明碼 namespace 漏洞路徑,僅遷移期用,正式勿開
|
||||
# ALLOW_PLAINTEXT_NAMESPACE 【不安全,SUNSET】="true" 恢復舊明碼 namespace 漏洞路徑,僅遷移期,驗收完即刪
|
||||
# (追蹤移除的 issue 見 OAUTH.md §5)
|
||||
|
||||
# Route — MCP 搬進 arcrun 主庫後改用 arcrun.dev zone(與其他 worker 一致)。
|
||||
# 舊的 studio.finally.click 是 inkstone 平台 zone,arcrun 帳號沒有該 zone → 部署 route 失敗。
|
||||
|
||||
Reference in New Issue
Block a user