Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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 <token>`、ブラウザからは 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`)
Expand Down Expand Up @@ -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="<long-random-token>" \
INGEST_KEYS="<per-project-public-key>" \
ALLOWED_ORIGINS="https://demo.example.com" \
PUBLIC_BASE_URL="https://feedback.example.com" \
RECEIVER_TRUST_PROXY=1 \
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion dist/patchloop-widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
Expand Down
89 changes: 79 additions & 10 deletions server/receive.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
// "<expiresAtMs>.<HMAC(RECEIVER_TOKEN, expiresAtMs)>" — a derived credential,
// never the raw token, valid for 7 days. Secure is tied to the deploy's public
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand All @@ -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) {
Expand All @@ -531,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
Expand Down
1 change: 1 addition & 0 deletions server/receiver.config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"trustProxy": false,
"receiverToken": "",
"allowedOrigins": [],
"ingestKeys": [],
"slackWebhookUrl": "",
"slackImageMode": "auto",
"slackBotToken": "",
Expand Down
Loading
Loading