mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
fix: prevent race conditions with KV locks in dispatch and group provisioning
This commit is contained in:
parent
17eefd3c8e
commit
7ebd9aa63f
4 changed files with 194 additions and 79 deletions
|
|
@ -197,6 +197,53 @@ export async function dispatchEvent(
|
||||||
if (message.updateKey) {
|
if (message.updateKey) {
|
||||||
const groupPrefix = route.groupId ? `${route.groupId}:` : "";
|
const groupPrefix = route.groupId ? `${route.groupId}:` : "";
|
||||||
const kvKey = `msg:${groupPrefix}${route.id}:${message.updateKey}:${targetStr}`;
|
const kvKey = `msg:${groupPrefix}${route.id}:${message.updateKey}:${targetStr}`;
|
||||||
|
const lockKey = `msg:lock:${kvKey}`;
|
||||||
|
|
||||||
|
// Acquire a short-lived lock so concurrent events for the same
|
||||||
|
// updateKey don't race (both read null, both send, the later put
|
||||||
|
// overwrites). KV is eventually consistent so the lock is
|
||||||
|
// best-effort; a retry loop further shrinks the window.
|
||||||
|
let locked = false;
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const holder = await env.KV.get(lockKey);
|
||||||
|
if (holder) {
|
||||||
|
const existing = await env.KV.get(kvKey);
|
||||||
|
if (existing) {
|
||||||
|
result = await driver.edit(message, target, env, existing);
|
||||||
|
if (result.ok || /not modified/i.test(result.error ?? "")) {
|
||||||
|
const ok = result.ok || /not modified/i.test(result.error ?? "");
|
||||||
|
attempts.push({
|
||||||
|
groupId: route.groupId,
|
||||||
|
routeId: route.id,
|
||||||
|
routeName: route.name,
|
||||||
|
target: targetStr,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
await recordSend(env.DB, {
|
||||||
|
...base,
|
||||||
|
ok: true,
|
||||||
|
status: result.status,
|
||||||
|
messageId: existing,
|
||||||
|
platform: driver.id,
|
||||||
|
attempts: result.attempts,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
errorCode: result.errorCode,
|
||||||
|
});
|
||||||
|
if (!ok) await env.KV.delete(kvKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await env.KV.delete(kvKey);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 50 * (attempt + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await env.KV.put(lockKey, "1", { expirationTtl: 60 });
|
||||||
|
locked = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const existingId = await env.KV.get(kvKey);
|
const existingId = await env.KV.get(kvKey);
|
||||||
if (existingId) {
|
if (existingId) {
|
||||||
result = await driver.edit(message, target, env, existingId);
|
result = await driver.edit(message, target, env, existingId);
|
||||||
|
|
@ -246,11 +293,35 @@ export async function dispatchEvent(
|
||||||
if (result.ok && result.messageId) {
|
if (result.ok && result.messageId) {
|
||||||
await env.KV.put(kvKey, result.messageId, { expirationTtl: 604800 });
|
await env.KV.put(kvKey, result.messageId, { expirationTtl: 604800 });
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (locked) await env.KV.delete(lockKey);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
result = await driver.send(message, target, env);
|
result = await driver.send(message, target, env);
|
||||||
}
|
}
|
||||||
const durationMs = Date.now() - started;
|
const durationMs = Date.now() - started;
|
||||||
if (!result.ok) throw new Error(result.error ?? "Send failed");
|
if (!result.ok) {
|
||||||
|
const error = result.error ?? "Send failed";
|
||||||
|
attempts.push({
|
||||||
|
groupId: route.groupId,
|
||||||
|
routeId: route.id,
|
||||||
|
routeName: route.name,
|
||||||
|
target: targetStr,
|
||||||
|
ok: false,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
await recordSend(env.DB, {
|
||||||
|
...base,
|
||||||
|
ok: false,
|
||||||
|
error,
|
||||||
|
durationMs,
|
||||||
|
status: result.status,
|
||||||
|
platform: driver.id,
|
||||||
|
attempts: result.attempts,
|
||||||
|
errorCode: result.errorCode,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
attempts.push({
|
attempts.push({
|
||||||
groupId: route.groupId,
|
groupId: route.groupId,
|
||||||
routeId: route.id,
|
routeId: route.id,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,19 @@ const GITHUB_COMMENT_RE =
|
||||||
/github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/\d+#issuecomment-(\d+)/;
|
/github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/\d+#issuecomment-(\d+)/;
|
||||||
const GITHUB_ISSUE_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/;
|
const GITHUB_ISSUE_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a pipe-delimited custom-id suffix into [owner, repo, number]. The
|
||||||
|
* number is always the last segment (arbitrary-length owner/repo can contain
|
||||||
|
* pipes on non-GitHub forges), so we split from the right.
|
||||||
|
*/
|
||||||
|
function splitThree(rest: string): [string, string, string] {
|
||||||
|
const parts = rest.split("|");
|
||||||
|
const number = parts.pop();
|
||||||
|
const repo = parts.pop();
|
||||||
|
const owner = parts.join("|");
|
||||||
|
return [owner ?? "", repo ?? "", number ?? ""];
|
||||||
|
}
|
||||||
|
|
||||||
interface Interaction {
|
interface Interaction {
|
||||||
id: string;
|
id: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
|
@ -101,7 +114,8 @@ export async function handleInteractionRequest(request: Request, env: Env): Prom
|
||||||
);
|
);
|
||||||
return new Response("Invalid signature", { status: 401 });
|
return new Response("Invalid signature", { status: 401 });
|
||||||
}
|
}
|
||||||
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TIMESTAMP_TOLERANCE_SECONDS) {
|
const ts = Number(timestamp);
|
||||||
|
if (!Number.isFinite(ts) || Math.abs(Math.floor(Date.now() / 1000) - ts) > TIMESTAMP_TOLERANCE_SECONDS) {
|
||||||
return new Response("Invalid signature", { status: 401 });
|
return new Response("Invalid signature", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,7 +301,7 @@ async function handleButton(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [owner, repo, number] = rest.split("|");
|
const [owner, repo, number] = splitThree(rest);
|
||||||
if (!owner || !repo || !number) return;
|
if (!owner || !repo || !number) return;
|
||||||
|
|
||||||
// Acknowledge first (deferred, ephemeral) so the clicker sees a spinner
|
// Acknowledge first (deferred, ephemeral) so the clicker sees a spinner
|
||||||
|
|
@ -494,7 +508,7 @@ async function modalSubmit(
|
||||||
|
|
||||||
// ghc|add|owner|repo|issueNumber
|
// ghc|add|owner|repo|issueNumber
|
||||||
if (customId.startsWith(MODAL_ADD)) {
|
if (customId.startsWith(MODAL_ADD)) {
|
||||||
const [owner, repo, number] = customId.slice(MODAL_ADD.length).split("|");
|
const [owner, repo, number] = splitThree(customId.slice(MODAL_ADD.length));
|
||||||
if (!owner || !repo || !number)
|
if (!owner || !repo || !number)
|
||||||
return respond(env, id, token, "内部错误:无法解析目标 issue。");
|
return respond(env, id, token, "内部错误:无法解析目标 issue。");
|
||||||
try {
|
try {
|
||||||
|
|
@ -514,7 +528,7 @@ async function modalSubmit(
|
||||||
|
|
||||||
// ghc|edit|owner|repo|commentId
|
// ghc|edit|owner|repo|commentId
|
||||||
if (customId.startsWith(MODAL_EDIT)) {
|
if (customId.startsWith(MODAL_EDIT)) {
|
||||||
const [owner, repo, commentId] = customId.slice(MODAL_EDIT.length).split("|");
|
const [owner, repo, commentId] = splitThree(customId.slice(MODAL_EDIT.length));
|
||||||
if (!owner || !repo || !commentId)
|
if (!owner || !repo || !commentId)
|
||||||
return respond(env, id, token, "内部错误:无法解析目标评论。");
|
return respond(env, id, token, "内部错误:无法解析目标评论。");
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,24 @@ export async function ensureInstallationGroup(
|
||||||
installationId: number,
|
installationId: number,
|
||||||
accountLogin: string,
|
accountLogin: string,
|
||||||
): Promise<Group | null> {
|
): Promise<Group | null> {
|
||||||
|
// A per-installation lock serializes concurrent `installation.created`
|
||||||
|
// webhooks: without it two requests both pass the "no group bound" check,
|
||||||
|
// both create `inst-{id}`, and the second save overwrites the first.
|
||||||
|
const lockKey = `inst:lock:${installationId}`;
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
const holder = await kv.get(lockKey);
|
||||||
|
if (!holder) break;
|
||||||
|
const groups = await loadGroups(kv);
|
||||||
|
const existing = groups.find((g) => g.installationId === installationId);
|
||||||
|
if (existing) return existing;
|
||||||
|
await new Promise((r) => setTimeout(r, 40 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let locked = false;
|
||||||
|
try {
|
||||||
|
await kv.put(lockKey, "1", { expirationTtl: 60 });
|
||||||
|
locked = true;
|
||||||
|
|
||||||
const groups = await loadGroups(kv);
|
const groups = await loadGroups(kv);
|
||||||
const existing = groups.find((g) => g.installationId === installationId);
|
const existing = groups.find((g) => g.installationId === installationId);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
@ -150,6 +168,9 @@ export async function ensureInstallationGroup(
|
||||||
dedicated ? groups.map((g) => (g.id === gid ? { ...g, ...group } : g)) : [...groups, group],
|
dedicated ? groups.map((g) => (g.id === gid ? { ...g, ...group } : g)) : [...groups, group],
|
||||||
);
|
);
|
||||||
return group;
|
return group;
|
||||||
|
} finally {
|
||||||
|
if (locked) await kv.delete(lockKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccessScope {
|
export interface AccessScope {
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,16 @@ export async function processWebhook(
|
||||||
return { status: 400, body: { error: "Unknown webhook provider" } };
|
return { status: 400, body: { error: "Unknown webhook provider" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await provider.verify(body, headers, effectiveEnv))) {
|
let verified = false;
|
||||||
|
try {
|
||||||
|
verified = await provider.verify(body, headers, effectiveEnv);
|
||||||
|
} catch (err) {
|
||||||
|
// A malformed secret or an unavailable crypto implementation must fail as
|
||||||
|
// a clean 401, never an uncaught 500.
|
||||||
|
log.warn({ provider: provider.id, err: String(err) }, "Webhook signature verification failed");
|
||||||
|
return { status: 401, body: { error: "Invalid signature" } };
|
||||||
|
}
|
||||||
|
if (!verified) {
|
||||||
// Log the actual cause: a missing provider secret is a deployment problem,
|
// Log the actual cause: a missing provider secret is a deployment problem,
|
||||||
// while a mismatched signature usually means the sender used the wrong secret.
|
// while a mismatched signature usually means the sender used the wrong secret.
|
||||||
const secret =
|
const secret =
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue