From 0204afb5063342e26e412d80884d2ba2d2076345 Mon Sep 17 00:00:00 2001 From: kosako Date: Sun, 5 Jul 2026 22:10:06 +0900 Subject: [PATCH 1/2] =?UTF-8?q?widget/receiver:=20ingest=20key=20=E3=81=AB?= =?UTF-8?q?=E3=82=88=E3=82=8B=20widget=E2=86=94receiver=20=E3=83=9A?= =?UTF-8?q?=E3=82=A2=E8=AA=8D=E8=A8=BC=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=99?= =?UTF-8?q?=E3=82=8B=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公開デプロイで唯一無認証だった受信面(POST /feedback)を保護する。 - receiver: INGEST_KEYS env(カンマ区切り)/ config ingestKeys(配列)を 追加。設定時は X-PatchLoop-Ingest-Key ヘッダーの一致を必須にし、 欠落・不一致は 401。route table の auth kind "ingest" として宣言し、 dispatch で一元適用(timing-safe 比較) - config の { key, projectId } 形式で key と projectId を紐付けでき、 紐付き key での投稿は payload の projectId 詐称を 403 で拒否、 省略時は key の projectId を補完(必ず帰属が付く) - CORS の Access-Control-Allow-Headers に X-PatchLoop-Ingest-Key を追加 (忘れると preflight で widget 送信が全滅する — issue の設計メモ) - widget: init option ingestKey を追加し、receiver POST にヘッダーで送る - 起動ログに ingest auth の有効/無効を出力。未設定は従来通り誰でも投稿 できるゼロ設定互換 キーはデモページに埋め込まれる公開キーで、秘密による認証ではない (プロジェクト識別・無差別 spam 抑止・ローテーション失効が目的)。 レビュアー個人の認証は将来スコープ(README の境界に明記)。 Closes #44 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LRdDjwxbAAaTMwwnzipkau --- README.md | 6 ++- dist/patchloop-widget.js | 10 +++- server/receive.js | 79 +++++++++++++++++++++++++++-- server/receiver.config.example.json | 1 + test/receiver.test.js | 56 ++++++++++++++++++++ widget/src/index.js | 10 +++- 6 files changed, 154 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a428ba6..bd67856 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ PatchLoop は、普通の HTML に `script` tag で埋め込める standalone wi - `feedbackStorageKey` (string, optional) — feedback list を保存する `localStorage` key。デフォルトは `patchloop:feedback` - `deliveryMode` (`"receiver"` | `"slack-webhook"` | `"download"` | `"none"`, optional) — 送信方式。デフォルトは `"receiver"` - `endpoint` (string, optional) — payload を `POST` する URL。未設定なら送信しない +- `ingestKey` (string, optional) — receiver に `X-PatchLoop-Ingest-Key` ヘッダーで送るプロジェクトごとの公開キー。receiver 側で `INGEST_KEYS` / `ingestKeys` を設定している場合は必須。ページに埋め込まれるため秘密ではなく、プロジェクト識別・無差別 spam の抑止・ローテーションによる失効が目的 - `slackWebhookUrl` (string, optional) — `deliveryMode: "slack-webhook"` 時にブラウザから直接送る Slack Incoming Webhook URL - `showDeliverySettings` (boolean, optional) — drawer 内に送信先切替 UI を表示するか。デフォルトは `false` - `captureScreenshot` (boolean, optional) — viewport snapshot を payload に含めるか。デフォルトは `true` @@ -199,6 +200,7 @@ feedback は組み込みの `node:sqlite`(`server/feedback.db`)に保存し - `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MS` / `RATE_LIMIT_MAX_CLIENTS` env(または config の `rateLimitMax` / `rateLimitWindowMs` / `rateLimitMaxClients`)で、IP ごとの固定窓レート制限を変更できます(デフォルト `120` req / `60000` ms、超過は `429` + `Retry-After`)。クライアント識別は既定で socket の remoteAddress。reverse proxy 配下では `RECEIVER_TRUST_PROXY` を `1` にしたときだけ `X-Forwarded-For` 先頭を使います(既定では直アクセスがヘッダーで識別を偽装できないようにするため) - `MAX_FEEDBACK_COUNT` env(または config の `maxFeedbackCount`、デフォルト `100000`)を超えると新規保存を `507` で拒否します。`SCREENSHOT_DISK_MAX_BYTES` env(または config の `screenshotDiskMaxBytes`、デフォルト `500000000`)を超える screenshot 書き込みも `507` で拒否します(起動時にディスク使用量を実測し、保存・削除で追跡) - `RECEIVER_TOKEN` env(または config の `receiverToken`)を設定すると、操作系 endpoint(`POST /import` / `POST /feedback/:id/status` / `DELETE /feedback/:id` / `POST /feedback/:id/github-issue`)と閲覧系 endpoint(`GET /`(inbox)/ `GET /feedback.json` / `GET /screenshots/:file`)に認証を必須にします。API からは `Authorization: Bearer `、ブラウザからは inbox のログインフォーム(`GET /login`)に同じ token を入力します。ログイン後は HMAC 派生値の HttpOnly cookie(有効期限 7 日、`SameSite=Lax`、`publicBaseUrl` が `https://` のとき `Secure` 付き)でセッションが維持され、生 token はブラウザに保存されません。token を変更すると全端末のセッションが即失効します。未設定ならローカルは認証なしで動作します(widget の `POST /feedback` と `GET /widget.js` は設定時も常に公開) +- `INGEST_KEYS` env(カンマ区切り)または config の `ingestKeys`(配列)を設定すると、widget の投稿(`POST /feedback`)に `X-PatchLoop-Ingest-Key` ヘッダーの一致を必須にします(不一致・欠落は `401`)。config では `"key文字列"` に加えて `{ "key": "...", "projectId": "..." }` 形式で **key と projectId を紐付け**でき、紐付いた key での投稿は payload の `projectId` 詐称を `403` で拒否し、省略時は key の projectId を補完します。キーはデモページに埋め込まれる公開キーで、秘密による認証ではありません(プロジェクト識別・spam 抑止・ローテーション失効が目的)。未設定なら従来通り誰でも投稿できます - `ALLOWED_ORIGINS` env(または config の `allowedOrigins`、env はカンマ区切り・config は配列)を設定すると、widget の投稿(`POST /feedback`)の CORS を許可 origin(`scheme://host[:port]` の完全一致)に制限します。JSON POST は必ず preflight されるため、リスト外 origin のブラウザ投稿は本体 POST の前にブラウザ側で遮断されます。未設定なら従来通り全 origin 許可(`*`)で、起動ログに警告が出ます。CORS ヘッダーが付くのは `POST /feedback` のみで、inbox・操作系・screenshot は同一オリジン利用のため CORS 自体を返しません。受信した feedback には `received: { origin, originAllowed }` が保存され、inbox の raw payload から確認できます(Origin ヘッダーは偽装可能なため参考情報です) - `SLACK_WEBHOOK_URL` env を設定すると、受信した feedback を Slack Incoming Webhook にも転送します - `SLACK_IMAGE_MODE` env で Slack 上の screenshot 表示方式を変更できます(`auto` / `link` / `block` / `upload` / `off`) @@ -274,12 +276,14 @@ receiver はデフォルトでローカルプロトタイプ前提(`127.0.0.1` - **HTTPS 終端は reverse proxy(nginx / Caddy / ALB など)で行う**: receiver 自体は HTTP のみです。proxy の背後では `HOST` の bind 先を proxy からのみ届く interface に限定し、rate limit がクライアント IP を正しく見るよう `RECEIVER_TRUST_PROXY=1` を設定します - **`RECEIVER_TOKEN` を必ず設定する**: 未設定のまま公開すると inbox・feedback データ・操作系がすべて無認証で露出します - **`ALLOWED_ORIGINS` にデモページの origin を列挙する**: widget からの投稿を想定した origin に絞ります +- **`INGEST_KEYS` を設定し、widget の init に `ingestKey` を渡す**: キーなしの `POST /feedback` を 401 で拒否できます。キーは公開キーなので漏えい前提で、プロジェクトごとに分けてローテーションできるようにしておきます - **`PUBLIC_BASE_URL` を `https://` の公開 URL にする**: Slack / GitHub に載せる screenshot link の到達性に加え、セッション cookie の `Secure` 属性がこの URL のスキームで決まります - **secrets(`RECEIVER_TOKEN` / `GITHUB_TOKEN` / `SLACK_WEBHOOK_URL` など)は env 注入を推奨**: env は config ファイルより優先されます。config ファイル(`receiver.config.json`)に書く場合は git 管理外・ファイル権限の管理下に置いてください - **死活監視は `GET /healthz` に張る**: 認証不要・rate limit 対象外で、store 疎通込みの 200 / 503 を返します。systemd / ALB からの停止は SIGTERM で graceful shutdown します(in-flight 完了を待つため、停止タイムアウトは 10 秒より長めに) ```sh RECEIVER_TOKEN="" \ +INGEST_KEYS="" \ ALLOWED_ORIGINS="https://demo.example.com" \ PUBLIC_BASE_URL="https://feedback.example.com" \ RECEIVER_TRUST_PROXY=1 \ @@ -350,7 +354,7 @@ GitHub Issue 作成は receiver inbox からの手動操作のみで、自動作 - Slack App / OAuth 連携 - 永続 DB - pixel-perfect なブラウザ screenshot capture -- widget↔receiver のペア認証(ingest key。受信面の認証は `RECEIVER_TOKEN` + inbox ログインで対応済み) +- レビュアー個人の認証(ingest key はプロジェクト単位の公開キーで、個人を識別しない。デモ側ログイン前提の署名付き token は将来スコープ) - AI PR 連携 ## License diff --git a/dist/patchloop-widget.js b/dist/patchloop-widget.js index d45bf73..5508c8f 100644 --- a/dist/patchloop-widget.js +++ b/dist/patchloop-widget.js @@ -392,6 +392,10 @@ const DEFAULTS = { projectId: "local-demo", demoId: "plain-html", endpoint: "", + // Public per-project key sent with receiver posts (#44). It ships in the + // page, so it identifies the project and blocks indiscriminate spam rather + // than acting as a secret. Empty = receiver runs with open ingest. + ingestKey: "", deliveryMode: "receiver", slackWebhookUrl: "", showDeliverySettings: false, @@ -1114,9 +1118,13 @@ function base64Encode(value) { async function postFeedback(payload) { try { + const headers = { "Content-Type": "application/json" }; + if (state.options.ingestKey) { + headers["X-PatchLoop-Ingest-Key"] = state.options.ingestKey; + } const response = await fetch(state.options.endpoint, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify(payload) }); payload.delivery = { ok: response.ok, status: response.status }; diff --git a/server/receive.js b/server/receive.js index 8d017b7..2a69ff1 100644 --- a/server/receive.js +++ b/server/receive.js @@ -73,6 +73,15 @@ const RECEIVER_TOKEN = process.env.RECEIVER_TOKEN || config.receiverToken || ""; // (Access-Control-Allow-Origin: *) so zero-config local runs keep working; the // startup log warns about it. Entries are exact origins (scheme://host[:port]). const ALLOWED_ORIGINS = normalizeStringList(process.env.ALLOWED_ORIGINS || config.allowedOrigins).map(trimTrailingSlash); +// Public ingest keys for widget→receiver pair auth (#44). A key ships inside +// the public demo page, so this is not a secret-based credential: it exists to +// identify the project, stop indiscriminate spam, and allow rotation to cut a +// leaked deploy off. Config entries are either bare "key" strings or +// { key, projectId } objects — a bound projectId pins what the payload may +// claim (spoofing guard). The env form is a comma-separated list of bare keys. +// Unset keeps ingest open (zero-config local dev), noted in the startup log. +const INGEST_KEYS = normalizeIngestKeys(process.env.INGEST_KEYS || config.ingestKeys); +const INGEST_KEY_HEADER = "x-patchloop-ingest-key"; // Browser sessions for the inbox: the cookie value is // "." — a derived credential, // never the raw token, valid for 7 days. Secure is tied to the deploy's public @@ -179,6 +188,18 @@ function hasValidSessionCookie(req) { return safeTokenEqual(signature, sessionSignature(expiry)); } +// Resolves the request's ingest key header against the configured keys. +// Returns the matched { key, projectId } entry, or null when the header is +// missing or matches nothing. Compared timing-safe like the other credentials. +function ingestKeyEntryFor(req) { + const provided = req.headers[INGEST_KEY_HEADER]; + if (typeof provided !== "string" || !provided) return null; + for (const entry of INGEST_KEYS) { + if (safeTokenEqual(provided, entry.key)) return entry; + } + return null; +} + // Protected endpoints (management + reads) accept either the shared bearer // token (curl / API clients) or a valid session cookie (the inbox UI — its // same-origin fetches carry the HttpOnly cookie automatically, so inbox.js @@ -213,15 +234,16 @@ function redirect(res, location) { // Every route declares its auth and CORS policy so a new endpoint cannot // silently skip either check; dispatch applies them in one place instead of -// per-branch (#43). Auth kinds: "none" (public), "protected" (bearer token or +// per-branch (#43). Auth kinds: "none" (public), "ingest" (public ingest key +// when INGEST_KEYS is configured — #44), "protected" (bearer token or // session cookie, 401 on failure — APIs and resources), "page" (same // credentials, but an unauthenticated browser is redirected to the login form // instead of getting raw JSON). CORS headers are only emitted for routes with // cors: true — the inbox, management endpoints, and screenshots are same-origin // surfaces, so withholding the headers there is a second wall next to auth. -const ROUTE_AUTH_KINDS = new Set(["none", "protected", "page"]); +const ROUTE_AUTH_KINDS = new Set(["none", "ingest", "protected", "page"]); const ROUTES = [ - { method: "POST", pattern: /^\/feedback$/, auth: "none", cors: true, handler: handlePostFeedback }, + { method: "POST", pattern: /^\/feedback$/, auth: "ingest", cors: true, handler: handlePostFeedback }, { method: "GET", pattern: /^\/healthz$/, auth: "none", rateLimit: false, handler: handleGetHealthz }, { method: "GET", pattern: /^\/login$/, auth: "none", handler: handleGetLogin }, { method: "POST", pattern: /^\/login$/, auth: "none", handler: handlePostLogin }, @@ -297,7 +319,17 @@ const server = http.createServer((req, res) => { } if (matched) { - if (matched.auth !== "none" && !isAuthorizedRequest(req)) { + if (matched.auth === "ingest") { + // The key is resolved once here (declared on the route, like the other + // auth kinds) and handed to the handler via the request, which needs the + // matched entry for projectId binding. + const entry = ingestKeyEntryFor(req); + if (INGEST_KEYS.length > 0 && !entry) { + respondJson(res, 401, { ok: false, error: "Invalid ingest key" }); + return; + } + req.patchloopIngestKey = entry; + } else if (matched.auth !== "none" && !isAuthorizedRequest(req)) { if (matched.auth === "page") { redirect(res, "/login"); } else { @@ -386,6 +418,7 @@ async function start() { console.log(`[PatchLoop receiver] feedback db: ${DB_PATH}`); console.log(`[PatchLoop receiver] screenshot dir: ${SCREENSHOT_DIR}`); console.log(`[PatchLoop receiver] auth: ${RECEIVER_TOKEN ? "enabled (token + inbox login)" : "disabled (no RECEIVER_TOKEN)"}`); + console.log(`[PatchLoop receiver] ingest auth: ${INGEST_KEYS.length > 0 ? `enabled (${INGEST_KEYS.length} key${INGEST_KEYS.length > 1 ? "s" : ""})` : "open (no INGEST_KEYS)"}`); if (ALLOWED_ORIGINS.length > 0) { console.log(`[PatchLoop receiver] CORS allowlist: ${ALLOWED_ORIGINS.join(", ")}`); } else { @@ -425,7 +458,9 @@ function setCorsHeaders(req, res) { } res.setHeader("Access-Control-Allow-Origin", allowOrigin); res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + // The ingest key header must be allowlisted here or the browser preflight + // rejects every widget POST that carries a key (#44). + res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-PatchLoop-Ingest-Key"); } function warnIgnoredSetting(label, value, fallback) { @@ -504,6 +539,28 @@ function normalizeStringList(value) { return items.map((item) => String(item).trim()).filter(Boolean); } +// Accepts "key" strings and { key, projectId } objects (config), or a +// comma-separated string (env), and normalizes to { key, projectId|null }. +function normalizeIngestKeys(value) { + const items = Array.isArray(value) ? value : String(value || "").split(","); + const keys = []; + for (const item of items) { + if (item && typeof item === "object") { + const key = String(item.key || "").trim(); + if (!key) { + console.warn("[PatchLoop receiver] ignored ingestKeys entry without a key"); + continue; + } + const projectId = String(item.projectId || "").trim(); + keys.push({ key, projectId: projectId || null }); + continue; + } + const key = String(item || "").trim(); + if (key) keys.push({ key, projectId: null }); + } + return keys; +} + // Rejects new feedback once the store is full, before any screenshot is written // (so a rejected request leaves no orphan file). 507 signals the store, not the // request, is the problem. @@ -514,11 +571,23 @@ async function assertFeedbackCapacity(adding) { } } +// A key bound to a projectId pins what the payload may claim: a mismatch is a +// misconfigured (or spoofing) widget and is rejected; an omitted projectId is +// stamped from the key so the stored record is always attributed. +function enforceIngestProject(payload, keyEntry) { + if (!keyEntry || !keyEntry.projectId) return; + if (payload.projectId != null && payload.projectId !== keyEntry.projectId) { + throw httpError(`feedback.projectId does not match the ingest key's project (${keyEntry.projectId})`, 403); + } + payload.projectId = keyEntry.projectId; +} + function handlePostFeedback(req, res) { readJsonBody(req, res, async (payload) => { let screenshot; try { validateFeedbackPayload(payload); + enforceIngestProject(payload, req.patchloopIngestKey); await assertFeedbackCapacity(1); screenshot = saveScreenshot(payload.screenshot, payload.id); } catch (error) { diff --git a/server/receiver.config.example.json b/server/receiver.config.example.json index d90b8fd..e44a4b2 100644 --- a/server/receiver.config.example.json +++ b/server/receiver.config.example.json @@ -19,6 +19,7 @@ "trustProxy": false, "receiverToken": "", "allowedOrigins": [], + "ingestKeys": [], "slackWebhookUrl": "", "slackImageMode": "auto", "slackBotToken": "", diff --git a/test/receiver.test.js b/test/receiver.test.js index a4de9c2..9c1a226 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -1177,6 +1177,62 @@ test("without an allowlist, ingest CORS stays open and startup warns", async (t) assert.equal(inbox.headers.get("access-control-allow-origin"), null); }); +test("POST /feedback requires a configured ingest key and rejects wrong ones", async (t) => { + const receiver = await startReceiver(t, { INGEST_KEYS: "key-a, key-b" }); + assert.match(receiver.logs, /ingest auth: enabled \(2 keys\)/); + + // No key / wrong key → rejected before anything is validated or stored. + const missing = await postJson(`${receiver.baseUrl}/feedback`, feedbackPayload("pl_key_none")); + assert.equal(missing.status, 401); + const wrong = await postJson(`${receiver.baseUrl}/feedback`, feedbackPayload("pl_key_bad"), { "X-PatchLoop-Ingest-Key": "nope" }); + assert.equal(wrong.status, 401); + assert.deepEqual(await readStoredFeedback(receiver.dbPath), []); + + // Any configured key is accepted. + const ok = await postJson(`${receiver.baseUrl}/feedback`, feedbackPayload("pl_key_ok"), { "X-PatchLoop-Ingest-Key": "key-b" }); + assert.equal(ok.status, 201); + + // The preflight must allowlist the key header, or a browser widget carrying + // a key would be blocked before the POST is ever sent. + const preflight = await fetch(`${receiver.baseUrl}/feedback`, { method: "OPTIONS", headers: { Origin: "http://demo.example" } }); + assert.match(preflight.headers.get("access-control-allow-headers"), /X-PatchLoop-Ingest-Key/); + + // Other routes are untouched: import stays guarded by RECEIVER_TOKEN (unset + // here → open), not by ingest keys. + const imported = await postJson(`${receiver.baseUrl}/import`, { kind: "patchloop-feedback-bundle", version: 2, feedback: [feedbackPayload("pl_key_imp")] }); + assert.equal(imported.status, 201); +}); + +test("an ingest key bound to a projectId pins the payload's project", async (t) => { + // projectId binding is config-only (env keys are bare strings), so this test + // supplies a real config file. + const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "patchloop-ingest-config-")); + t.after(() => fs.rm(configDir, { recursive: true, force: true })); + const configPath = path.join(configDir, "receiver.config.json"); + await fs.writeFile(configPath, JSON.stringify({ ingestKeys: [{ key: "proj-key", projectId: "proj-a" }] })); + const receiver = await startReceiver(t, { PATCHLOOP_RECEIVER_CONFIG: configPath }); + const withKey = { "X-PatchLoop-Ingest-Key": "proj-key" }; + + // A matching projectId passes; a different one is a spoof (or misconfig). + const match = await postJson(`${receiver.baseUrl}/feedback`, { ...feedbackPayload("pl_proj_ok"), projectId: "proj-a" }, withKey); + assert.equal(match.status, 201); + const spoofed = await postJson(`${receiver.baseUrl}/feedback`, { ...feedbackPayload("pl_proj_spoof"), projectId: "proj-b" }, withKey); + assert.equal(spoofed.status, 403); + assert.match(spoofed.body.error, /does not match the ingest key's project/); + + // An omitted projectId is stamped from the key, so the record is attributed. + const omitted = feedbackPayload("pl_proj_stamped"); + delete omitted.projectId; + const stamped = await postJson(`${receiver.baseUrl}/feedback`, omitted, withKey); + assert.equal(stamped.status, 201); + + const stored = await readStoredFeedback(receiver.dbPath); + const byId = Object.fromEntries(stored.map((item) => [item.id, item.projectId])); + assert.equal(byId.pl_proj_ok, "proj-a"); + assert.equal(byId.pl_proj_stamped, "proj-a"); + assert.equal(byId.pl_proj_spoof, undefined); +}); + test("GET /healthz reports liveness and is exempt from rate limiting", async (t) => { const receiver = await startReceiver(t, { RATE_LIMIT_MAX: "1" }); diff --git a/widget/src/index.js b/widget/src/index.js index c7504ce..3bdcd90 100644 --- a/widget/src/index.js +++ b/widget/src/index.js @@ -9,6 +9,10 @@ const DEFAULTS = { projectId: "local-demo", demoId: "plain-html", endpoint: "", + // Public per-project key sent with receiver posts (#44). It ships in the + // page, so it identifies the project and blocks indiscriminate spam rather + // than acting as a secret. Empty = receiver runs with open ingest. + ingestKey: "", deliveryMode: "receiver", slackWebhookUrl: "", showDeliverySettings: false, @@ -731,9 +735,13 @@ function base64Encode(value) { async function postFeedback(payload) { try { + const headers = { "Content-Type": "application/json" }; + if (state.options.ingestKey) { + headers["X-PatchLoop-Ingest-Key"] = state.options.ingestKey; + } const response = await fetch(state.options.endpoint, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify(payload) }); payload.delivery = { ok: response.ok, status: response.status }; From 05e6dd2bd7351d13e902e445ea0c7b6c526fc0e4 Mon Sep 17 00:00:00 2001 From: kosako Date: Sun, 5 Jul 2026 22:13:11 +0900 Subject: [PATCH 2/2] =?UTF-8?q?receiver:=20received.origin=20=E3=82=B3?= =?UTF-8?q?=E3=83=A1=E3=83=B3=E3=83=88=E3=81=AE=E5=8F=A4=E3=81=84=20#44=20?= =?UTF-8?q?=E4=BA=88=E5=91=8A=E3=82=92=E5=AE=9F=E6=85=8B=E3=81=AB=E5=90=88?= =?UTF-8?q?=E3=82=8F=E3=81=9B=E3=82=8B=20(#106=20=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E5=AF=BE=E5=BF=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ingest key は公開キーで unforgeable ではないため、コメントの文言を 「ingest key / project binding と並ぶ弱シグナル」に更新(Codex レビュー 🟡)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LRdDjwxbAAaTMwwnzipkau --- server/receive.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/receive.js b/server/receive.js index 2a69ff1..c828b48 100644 --- a/server/receive.js +++ b/server/receive.js @@ -600,11 +600,11 @@ function handlePostFeedback(req, res) { schemaVersion: DEFAULT_SCHEMA_VERSION, ...payload, screenshot, - // Weak provenance signal for triage (#44 will add an unforgeable ingest - // key): the browser-sent Origin and whether the allowlist would have let - // a browser post it. Origin-less clients (curl, scripts) are not subject - // to CORS, so they record originAllowed: true. Set after the payload - // spread so a crafted payload cannot supply its own value. + // Weak provenance signal for triage, alongside the ingest key / project + // binding (#44): the browser-sent Origin and whether the allowlist would + // have let a browser post it. Origin-less clients (curl, scripts) are not + // subject to CORS, so they record originAllowed: true. Set after the + // payload spread so a crafted payload cannot supply its own value. received: { origin: typeof req.headers.origin === "string" ? req.headers.origin : null, originAllowed: ALLOWED_ORIGINS.length === 0