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) {
|
||||
const groupPrefix = route.groupId ? `${route.groupId}:` : "";
|
||||
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);
|
||||
if (existingId) {
|
||||
result = await driver.edit(message, target, env, existingId);
|
||||
|
|
@ -246,11 +293,35 @@ export async function dispatchEvent(
|
|||
if (result.ok && result.messageId) {
|
||||
await env.KV.put(kvKey, result.messageId, { expirationTtl: 604800 });
|
||||
}
|
||||
} finally {
|
||||
if (locked) await env.KV.delete(lockKey);
|
||||
}
|
||||
} else {
|
||||
result = await driver.send(message, target, env);
|
||||
}
|
||||
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({
|
||||
groupId: route.groupId,
|
||||
routeId: route.id,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,19 @@ const GITHUB_COMMENT_RE =
|
|||
/github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/\d+#issuecomment-(\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 {
|
||||
id: string;
|
||||
token: string;
|
||||
|
|
@ -101,7 +114,8 @@ export async function handleInteractionRequest(request: Request, env: Env): Prom
|
|||
);
|
||||
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 });
|
||||
}
|
||||
|
||||
|
|
@ -287,7 +301,7 @@ async function handleButton(
|
|||
return;
|
||||
}
|
||||
|
||||
const [owner, repo, number] = rest.split("|");
|
||||
const [owner, repo, number] = splitThree(rest);
|
||||
if (!owner || !repo || !number) return;
|
||||
|
||||
// Acknowledge first (deferred, ephemeral) so the clicker sees a spinner
|
||||
|
|
@ -494,7 +508,7 @@ async function modalSubmit(
|
|||
|
||||
// ghc|add|owner|repo|issueNumber
|
||||
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)
|
||||
return respond(env, id, token, "内部错误:无法解析目标 issue。");
|
||||
try {
|
||||
|
|
@ -514,7 +528,7 @@ async function modalSubmit(
|
|||
|
||||
// ghc|edit|owner|repo|commentId
|
||||
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)
|
||||
return respond(env, id, token, "内部错误:无法解析目标评论。");
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -120,6 +120,24 @@ export async function ensureInstallationGroup(
|
|||
installationId: number,
|
||||
accountLogin: string,
|
||||
): 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 existing = groups.find((g) => g.installationId === installationId);
|
||||
if (existing) return existing;
|
||||
|
|
@ -150,6 +168,9 @@ export async function ensureInstallationGroup(
|
|||
dedicated ? groups.map((g) => (g.id === gid ? { ...g, ...group } : g)) : [...groups, group],
|
||||
);
|
||||
return group;
|
||||
} finally {
|
||||
if (locked) await kv.delete(lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
export interface AccessScope {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,16 @@ export async function processWebhook(
|
|||
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,
|
||||
// while a mismatched signature usually means the sender used the wrong secret.
|
||||
const secret =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue