Skip to content

Commit cc685c9

Browse files
committed
Harden webhook processing and visibility
Adds webhook delivery idempotency for pipeline creation by storing `webhook_delivery_id` and enforcing one pipeline per (delivery, workflow), preventing duplicate runs/checks on retries. Webhook processing now fans out correctly to all workspace connections of the same GitHub repo, records per-workspace timeline events, and exposes deployment-wide webhook signature rejection health in API/UI (new sync KPI and empty-timeline remediation state). Also raises webhook body/rate limits to match real GitHub payload behavior and adds a runbook for diagnosing 401 webhook rejections.
1 parent 9f188c0 commit cc685c9

21 files changed

Lines changed: 894 additions & 261 deletions

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -638,8 +638,9 @@ into a traversal-safe tar streamed into the container via the Docker archive API
638638
hop the proxy appended, so per-client fairness survives a proxy without becoming
639639
spoofable (same trust model as the session display IP)
640640
- **Per-scope body limits** (not global): 64 KB on `/auth` + `/api`, 1 MiB on
641-
`/webhooks/github` and `POST …/workflows/validate` — an outer global limit would cap
642-
webhook payloads, so don't reintroduce one
641+
`POST …/workflows/validate`, and 25 MiB on `/webhooks/github` (GitHub documents
642+
payloads up to 25 MB; a tighter cap 413s large pushes and permanently loses the
643+
event) — an outer global limit would cap webhook payloads, so don't reintroduce one
643644
- Errors are sanitized: clients get stable codes, details stay in tracing logs;
644645
`sync_error` values are static category strings, never upstream response bodies
645646
- Every `/api/*` endpoint authenticates via the `CurrentUser` extractor (401 without a
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
-- Webhook-triggered pipelines record the delivery that created them, so a
2+
-- retried delivery (crash or commit failure between side effects and the
3+
-- completion transaction) can never create the same pipeline twice.
4+
-- NULL for manual dispatch/rerun pipelines.
5+
ALTER TABLE pipelines ADD COLUMN webhook_delivery_id TEXT;
6+
7+
-- One pipeline per (delivery, workflow). Partial: manual pipelines are
8+
-- unconstrained. workflow_id is already workspace-scoped, so one delivery
9+
-- fanning out to multiple workspaces still creates one pipeline per
10+
-- workspace without conflicting.
11+
CREATE UNIQUE INDEX pipelines_delivery_workflow_uq
12+
ON pipelines (webhook_delivery_id, workflow_id)
13+
WHERE webhook_delivery_id IS NOT NULL;

backend/src/db/pipelines.rs

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,33 @@ pub struct NewPipeline<'a> {
2727
pub timeout_seconds: i32,
2828
pub job_timeout_seconds: i32,
2929
pub request_id: Option<&'a str>,
30+
/// Webhook delivery that triggered this pipeline (None for manual
31+
/// dispatch/rerun). One pipeline per (delivery, workflow) — a retried
32+
/// delivery returns the existing row instead of creating a duplicate.
33+
pub webhook_delivery_id: Option<&'a str>,
3034
}
3135

3236
/// Create the pipeline, its jobs, the creation ledger entry, and the audit
3337
/// row in one transaction. The per-repository number is claimed race-free
34-
/// through pipeline_counters.
38+
/// through pipeline_counters. Returns `(pipeline, jobs, newly_created)`:
39+
/// when `webhook_delivery_id` is set and a pipeline for that
40+
/// (delivery, workflow) already exists — a retried delivery — the existing
41+
/// row comes back with `newly_created = false` and an empty jobs vec.
3542
pub async fn create(
3643
pool: &PgPool,
3744
new: &NewPipeline<'_>,
3845
jobs: &[PlannedJob],
39-
) -> sqlx::Result<(Pipeline, Vec<PipelineJob>)> {
46+
) -> sqlx::Result<(Pipeline, Vec<PipelineJob>, bool)> {
4047
let mut tx = pool.begin().await?;
4148

49+
// Idempotency guard BEFORE the counter bump so retries never burn
50+
// pipeline numbers.
51+
if let Some(delivery_id) = new.webhook_delivery_id
52+
&& let Some(existing) = find_by_delivery(&mut tx, delivery_id, new.workflow_id).await?
53+
{
54+
return Ok((existing, Vec::new(), false));
55+
}
56+
4257
let (number,): (i32,) = sqlx::query_as(
4358
r#"
4459
INSERT INTO pipeline_counters (repository_id, next_number)
@@ -52,14 +67,15 @@ pub async fn create(
5267
.fetch_one(&mut *tx)
5368
.await?;
5469

55-
let pipeline = sqlx::query_as::<_, Pipeline>(
70+
let inserted = sqlx::query_as::<_, Pipeline>(
5671
r#"
5772
INSERT INTO pipelines
5873
(workspace_id, repository_id, workflow_id, workflow_name, workflow_path,
5974
number, trigger, triggered_by, commit_sha, commit_message, commit_author,
6075
actor_login, actor_avatar_url, git_ref, trigger_inputs, pr_number,
61-
timeout_seconds)
62-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
76+
timeout_seconds, webhook_delivery_id)
77+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16,
78+
$17, $18)
6379
RETURNING *
6480
"#,
6581
)
@@ -80,8 +96,31 @@ pub async fn create(
8096
.bind(new.trigger_inputs)
8197
.bind(new.pr_number)
8298
.bind(new.timeout_seconds)
99+
.bind(new.webhook_delivery_id)
83100
.fetch_one(&mut *tx)
84-
.await?;
101+
.await;
102+
103+
let pipeline = match inserted {
104+
Ok(pipeline) => pipeline,
105+
// A concurrent processor won the (delivery, workflow) race: the tx
106+
// (including the counter bump) rolls back and the winner's row is
107+
// returned instead.
108+
Err(error) if is_delivery_conflict(&error) => {
109+
drop(tx);
110+
let delivery_id = new
111+
.webhook_delivery_id
112+
.expect("delivery conflict requires a delivery id");
113+
let existing = sqlx::query_as::<_, Pipeline>(
114+
"SELECT * FROM pipelines WHERE webhook_delivery_id = $1 AND workflow_id = $2",
115+
)
116+
.bind(delivery_id)
117+
.bind(new.workflow_id)
118+
.fetch_one(pool)
119+
.await?;
120+
return Ok((existing, Vec::new(), false));
121+
}
122+
Err(error) => return Err(error),
123+
};
85124

86125
let mut job_rows = Vec::with_capacity(jobs.len());
87126
for job in jobs {
@@ -142,7 +181,32 @@ pub async fn create(
142181
.await?;
143182

144183
tx.commit().await?;
145-
Ok((pipeline, job_rows))
184+
Ok((pipeline, job_rows, true))
185+
}
186+
187+
/// Existing pipeline for a (webhook delivery, workflow) pair, if any.
188+
async fn find_by_delivery(
189+
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
190+
delivery_id: &str,
191+
workflow_id: Uuid,
192+
) -> sqlx::Result<Option<Pipeline>> {
193+
sqlx::query_as::<_, Pipeline>(
194+
"SELECT * FROM pipelines WHERE webhook_delivery_id = $1 AND workflow_id = $2",
195+
)
196+
.bind(delivery_id)
197+
.bind(workflow_id)
198+
.fetch_optional(&mut **tx)
199+
.await
200+
}
201+
202+
/// True when an insert failed on the pipelines_delivery_workflow_uq partial
203+
/// unique index (concurrent creation for the same delivery + workflow).
204+
fn is_delivery_conflict(error: &sqlx::Error) -> bool {
205+
matches!(
206+
error,
207+
sqlx::Error::Database(db_err)
208+
if db_err.constraint() == Some("pipelines_delivery_workflow_uq")
209+
)
146210
}
147211

148212
pub struct ListFilter {

backend/src/db/repositories.rs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,14 +120,22 @@ pub async fn find_for_workspace(
120120
.await
121121
}
122122

123-
pub async fn find_by_github_id(
123+
/// Every workspace connection of a GitHub repository. `github_repo_id` is
124+
/// only unique per (workspace_id, github_repo_id) — the same repo can be
125+
/// connected in several workspaces, and webhook processing must fan out to
126+
/// ALL of them (a single-row lookup would nondeterministically feed one
127+
/// workspace's timeline/pipelines and starve the others). Deterministic
128+
/// order for stable processing.
129+
pub async fn find_all_by_github_id(
124130
pool: &PgPool,
125131
github_repo_id: i64,
126-
) -> sqlx::Result<Option<Repository>> {
127-
sqlx::query_as::<_, Repository>("SELECT * FROM repositories WHERE github_repo_id = $1")
128-
.bind(github_repo_id)
129-
.fetch_optional(pool)
130-
.await
132+
) -> sqlx::Result<Vec<Repository>> {
133+
sqlx::query_as::<_, Repository>(
134+
"SELECT * FROM repositories WHERE github_repo_id = $1 ORDER BY created_at, id",
135+
)
136+
.bind(github_repo_id)
137+
.fetch_all(pool)
138+
.await
131139
}
132140

133141
pub async fn delete_for_workspace(

backend/src/db/webhook_deliveries.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
//! `services/webhook_processor.rs` claims rows one at a time and processes
44
//! them asynchronously. `delivery_id` (GitHub's `X-GitHub-Delivery`) is the
55
//! idempotency key — redeliveries keep the original id, so replays collapse.
6+
//! Per-repo event ordering holds only under a SINGLE consumer process
7+
//! (deployment runs `replicas: 1`); see `claim_next` below.
68
79
use chrono::{DateTime, Utc};
810
use sqlx::PgPool;
@@ -58,9 +60,11 @@ pub async fn insert(
5860
}
5961

6062
/// Claim the oldest pending delivery for processing. `FOR UPDATE SKIP
61-
/// LOCKED` keeps concurrent claimers (multiple backend instances) from
62-
/// blocking each other; the single in-process consumer drains sequentially,
63-
/// which preserves per-repo event ordering.
63+
/// LOCKED` keeps concurrent claimers (accidental multiple backend
64+
/// instances) from blocking each other, but it does NOT preserve ordering
65+
/// across instances — per-repo event ordering is guaranteed only by the
66+
/// single sequential in-process consumer (deployment mandates
67+
/// `replicas: 1`; see docs/deploy-backend-dokploy.md).
6468
pub async fn claim_next(pool: &PgPool) -> sqlx::Result<Option<ClaimedDelivery>> {
6569
sqlx::query_as::<_, ClaimedDelivery>(
6670
r#"

backend/src/handlers/github_webhooks.rs

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use sha2::Sha256;
1919

2020
use crate::db;
2121
use crate::services::github_app;
22+
use crate::services::webhook_stats::SignatureError;
2223
use crate::state::AppState;
2324

2425
type HmacSha256 = Hmac<Sha256>;
@@ -152,6 +153,11 @@ pub async fn receive(
152153

153154
// 1. Authenticate the delivery before touching the payload.
154155
if let Err(cause) = verify_signature(&state.config.github_webhook_secret, &headers, &body) {
156+
// Rejections never persist (unauthenticated input must not reach
157+
// Postgres), but the static cause feeds the in-memory gauge so the
158+
// repository health panel can show "deliveries are being rejected"
159+
// instead of a silently empty timeline.
160+
state.webhook_auth.record(cause);
155161
// Static cause only — never the signature, the expected digest, the
156162
// secret, or any body bytes. `cause=mismatch` means the configured
157163
// secret differs from the App's; `cause=missing_header` means the App
@@ -357,36 +363,8 @@ fn header_str(headers: &HeaderMap, name: &str) -> Option<String> {
357363
Some(value.to_string())
358364
}
359365

360-
/// Why a delivery failed verification. Static category strings only — the
361-
/// house rule for every operator-facing failure label (`sync_error`,
362-
/// `error_category`). The cause is logged, never returned to the caller: it
363-
/// distinguishes a misconfiguration from an attack for the operator without
364-
/// telling an attacker which of their guesses got closer.
365-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366-
enum SignatureError {
367-
/// No `X-Hub-Signature-256` at all → the App has no webhook secret set.
368-
MissingHeader,
369-
/// Header present but not readable as ASCII.
370-
MalformedHeader,
371-
/// Not `sha256=…` — e.g. a sha1-only sender.
372-
BadPrefix,
373-
/// The digest after `sha256=` isn't hex.
374-
InvalidHex,
375-
/// A real HMAC mismatch → the configured secret differs from GitHub's.
376-
Mismatch,
377-
}
378-
379-
impl SignatureError {
380-
fn as_str(self) -> &'static str {
381-
match self {
382-
Self::MissingHeader => "missing_header",
383-
Self::MalformedHeader => "malformed_header",
384-
Self::BadPrefix => "bad_prefix",
385-
Self::InvalidHex => "invalid_hex",
386-
Self::Mismatch => "mismatch",
387-
}
388-
}
389-
}
366+
// `SignatureError` (the static rejection-cause vocabulary) lives in
367+
// `services/webhook_stats.rs` next to the rejection gauge it feeds.
390368

391369
/// HMAC-SHA256 over the raw body with the configured secret, compared in
392370
/// constant time against `X-Hub-Signature-256: sha256=<hex>`.

backend/src/handlers/pipelines.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ pub async fn rerun(
263263
// A rerun of a PR pipeline keeps its PR association visible.
264264
pr_number: original.pr_number,
265265
request_id,
266+
webhook_delivery_id: None,
266267
};
267268
let pipeline = pipeline_run::create_pipeline(
268269
&state,
@@ -273,7 +274,8 @@ pub async fn rerun(
273274
&workflow.raw_content,
274275
&ctx,
275276
)
276-
.await?;
277+
.await?
278+
.pipeline;
277279

278280
pipeline_run::record_event(
279281
&state,
@@ -496,6 +498,7 @@ pub async fn dispatch(
496498
inputs: inputs.as_ref(),
497499
pr_number: None,
498500
request_id,
501+
webhook_delivery_id: None,
499502
};
500503
let pipeline = pipeline_run::create_pipeline(
501504
&state,
@@ -506,7 +509,8 @@ pub async fn dispatch(
506509
&workflow.raw_content,
507510
&ctx,
508511
)
509-
.await?;
512+
.await?
513+
.pipeline;
510514

511515
let row = db::pipelines::find_row_for_workspace(&state.pool, workspace_id, pipeline.id)
512516
.await?

backend/src/handlers/repositories.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use crate::error::{AppError, AppResult};
1313
use crate::middleware::auth::CurrentUser;
1414
use crate::models::repository::{
1515
AvailableRepoResponse, BranchResponse, RepositoryEventResponse, RepositoryHealthResponse,
16-
RepositoryResponse, SyncRunResponse,
16+
RepositoryResponse, SyncRunResponse, WebhookAuthHealth,
1717
};
1818
use crate::models::workflow::WorkflowSummaryResponse;
1919
use crate::services::workspace_hub::WorkspaceEvent;
@@ -224,12 +224,21 @@ pub async fn detail(
224224
let pending_deliveries =
225225
db::webhook_deliveries::pending_count_for_repo(&state.pool, repository.github_repo_id)
226226
.await?;
227+
// Deployment-global signature-rejection gauge: a wrong webhook secret
228+
// rejects EVERY delivery before persistence, so without this the panel
229+
// could not distinguish "no pushes" from "all deliveries rejected".
230+
let rejections = state.webhook_auth.snapshot();
227231
let health = RepositoryHealthResponse {
228232
last_event_at: overview.last_event_at,
229233
last_event_outcome: overview.last_event_outcome,
230234
failed_events_24h: overview.failed_events_24h,
231235
pending_deliveries,
232236
checks_enabled: state.config.github_checks_enabled,
237+
webhook_auth: WebhookAuthHealth {
238+
rejections_24h: rejections.rejections_24h,
239+
last_rejected_at: rejections.last_rejected_at,
240+
last_cause: rejections.last_cause,
241+
},
233242
};
234243

235244
let workflow_count = workflows.len() as i64;

backend/src/models/repository.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,4 +216,19 @@ pub struct RepositoryHealthResponse {
216216
pub failed_events_24h: i64,
217217
pub pending_deliveries: i64,
218218
pub checks_enabled: bool,
219+
pub webhook_auth: WebhookAuthHealth,
220+
}
221+
222+
/// DEPLOYMENT-GLOBAL webhook signature-rejection gauge (a bad
223+
/// GITHUB_WEBHOOK_SECRET rejects every delivery, so this is not scoped to
224+
/// one repository). In-memory — resets on restart, which is the point: it
225+
/// answers "is the CURRENT deployment's secret wrong?".
226+
#[derive(Debug, Serialize)]
227+
#[serde(rename_all = "camelCase")]
228+
pub struct WebhookAuthHealth {
229+
pub rejections_24h: u64,
230+
pub last_rejected_at: Option<DateTime<Utc>>,
231+
/// Static category only:
232+
/// missing_header|malformed_header|bad_prefix|invalid_hex|mismatch.
233+
pub last_cause: Option<&'static str>,
219234
}

backend/src/routes/mod.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,14 @@ use crate::state::AppState;
2727

2828
/// Browser-facing surfaces: JSON API calls and OAuth redirects are small.
2929
const MAX_BODY_BYTES: usize = 64 * 1024;
30-
/// Webhook payloads (push events especially) and editor validation content
31-
/// legitimately exceed the browser budget.
30+
/// Editor validation content legitimately exceeds the browser budget.
3231
const LARGE_BODY_BYTES: usize = 1024 * 1024;
32+
/// GitHub webhook bodies: GitHub documents payloads up to 25 MB (large
33+
/// pushes carry hundreds of commit objects). A tighter cap would 413 those
34+
/// deliveries BEFORE the handler — GitHub retries into the same wall and the
35+
/// event is permanently lost. HMAC verification (one pass over the body)
36+
/// remains the real gate.
37+
const WEBHOOK_BODY_BYTES: usize = 25 * 1024 * 1024;
3338
/// Workspace logo uploads: raw image bytes through the backend (magic-byte
3439
/// verification happens server-side). Slightly above the 2 MiB image cap so
3540
/// the handler — not the transport layer — produces the friendly error.
@@ -471,19 +476,24 @@ pub fn build_router(state: AppState) -> anyhow::Result<Router> {
471476
.layer(GovernorLayer::new(api_governor));
472477

473478
// GitHub webhooks: server-to-server, authenticated by HMAC signature —
474-
// deliberately outside the CSRF layer and under the large body budget.
479+
// deliberately outside the CSRF layer and under the webhook body budget.
480+
// The limiter is only a DoS floor, sized generously: GitHub delivers
481+
// from a small shared egress-IP pool, so a per-IP key is effectively a
482+
// GLOBAL cap across every installation — a busy org must not be able to
483+
// 429 unrelated deliveries. HMAC verification is the real gate and a
484+
// rejection costs one hash pass.
475485
let webhook_governor = Arc::new(
476486
GovernorConfigBuilder::default()
477487
.key_extractor(rate_key)
478-
.per_second(10)
479-
.burst_size(20)
488+
.per_second(100)
489+
.burst_size(500)
480490
.finish()
481491
.expect("valid governor configuration"),
482492
);
483493
let webhook_routes = Router::new()
484494
.route("/github", post(github_webhooks::receive))
485495
.layer(GovernorLayer::new(webhook_governor))
486-
.layer(RequestBodyLimitLayer::new(LARGE_BODY_BYTES));
496+
.layer(RequestBodyLimitLayer::new(WEBHOOK_BODY_BYTES));
487497

488498
// WebSocket surfaces live OUTSIDE the CSRF layer: native WebSockets
489499
// cannot send the X-Requested-With header. Each endpoint authenticates

0 commit comments

Comments
 (0)