fix(git-sync): redact webhook secrets from workspace export; fix doc endpoints

- Export (P1): strip the server-owned auto_pull state (webhook secret/id/error
  + synced sha + last pull status) from git_sync before it is written into an
  export's settings.json for both settings formats. The HMAC webhook secret
  must never leave the server (matching the GET-settings redaction), and a
  re-imported workspace must not inherit another install's hook/sync state.
- Docs: the webhook receiver is a single per-workspace endpoint
  /api/w/{workspace}/github_app/webhook (host-aware for managed + self-managed);
  update the stale push_webhook/{id} and instance-global /api/github_app/webhook
  references.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PP5gBSPfo1YtkL1sWVAjJm
This commit is contained in:
hugocasa
2026-07-06 22:53:29 +02:00
parent 5b1f02bf92
commit ec7ee12ac2
2 changed files with 42 additions and 15 deletions
+31 -2
View File
@@ -1422,6 +1422,35 @@ pub(crate) async fn tarball_workspace(
.await?;
// Use v2 format only if explicitly requested, otherwise use v1 (legacy) for backward compatibility
// Server-owned auto-pull state (the HMAC webhook secret + hook id/error and
// the synced-sha / last-pull status) must never leave the server: keep it out
// of export archives and synced repos, and don't let a re-imported workspace
// inherit another install's hook/sync state. Mirrors the GET-settings redaction.
fn redact_git_sync_for_export(git_sync: Option<Value>) -> Option<Value> {
let mut git_sync = git_sync?;
if let Some(repos) = git_sync
.get_mut("repositories")
.and_then(|r| r.as_array_mut())
{
for repo in repos {
if let Some(auto_pull) =
repo.get_mut("auto_pull").and_then(|a| a.as_object_mut())
{
for field in [
"webhook_secret",
"webhook_id",
"webhook_error",
"last_synced_sha",
"last_pull_status",
] {
auto_pull.remove(field);
}
}
}
}
Some(git_sync)
}
let settings_str = if settings_version.as_deref() == Some("v2") {
let settings = SimplifiedSettings {
auto_invite: row.auto_invite,
@@ -1431,7 +1460,7 @@ pub(crate) async fn tarball_workspace(
success_handler: row.success_handler,
ai_config: row.ai_config,
large_file_storage: row.large_file_storage,
git_sync: row.git_sync,
git_sync: redact_git_sync_for_export(row.git_sync),
default_app: row.default_app,
default_scripts: row.default_scripts,
name: row.name.clone().unwrap_or_default(),
@@ -1502,7 +1531,7 @@ pub(crate) async fn tarball_workspace(
error_handler_muted_on_cancel,
ai_config: row.ai_config,
large_file_storage: row.large_file_storage,
git_sync: row.git_sync,
git_sync: redact_git_sync_for_export(row.git_sync),
default_app: row.default_app,
default_scripts: row.default_scripts,
name: row.name.unwrap_or_default(),
+11 -13
View File
@@ -87,7 +87,7 @@ flowchart LR
R[Repo] -- "push event" --> W[repo webhook<br/>created via API]
end
subgraph Instance
W -- "HMAC-verified POST" --> E["/api/w/:ws/github_app/push_webhook"]
W -- "HMAC-verified POST" --> E["/api/w/:ws/github_app/webhook"]
P[poller<br/>git ls-remote / schedule] --> REC
E --> REC[reconcile:<br/>ref match? sha moved?<br/>debounce]
REC --> J[pull job<br/>existing hub script, pull:true]
@@ -116,8 +116,8 @@ Flow when a repo is connected (or auto-pull is enabled on an existing repo):
1. Instance generates a per-repo secret, stored in the repo's git-sync settings.
2. Instance creates the webhook via API: events `["push"]` (later
`"pull_request"`, §11), URL
`{base_url}/api/w/{workspace}/github_app/push_webhook/{repo_settings_id}`,
secret set.
`{base_url}/api/w/{workspace}/github_app/webhook` (host-aware, so managed and
self-managed/GHES apps use the same per-workspace receiver), secret set.
3. GitHub immediately delivers a `ping` event. If the ping is not received
within ~10 s, the instance deletes the hook and falls back to polling,
surfacing "instance not reachable from GitHub — using polling (interval Xm)"
@@ -139,9 +139,9 @@ the instance — no repo hooks needed, one webhook covers all installed repos:
- `GhesAppSettings` gains a "Webhook secret" field; the setup checklist changes
from "Uncheck Active under Webhook" to "set webhook URL to
`{base_url}/api/github_app/webhook` (instance-global endpoint), subscribe to
Push events, paste this generated secret".
- The instance-global endpoint verifies HMAC with the instance-level secret and
`{base_url}/api/w/{workspace}/github_app/webhook` (the per-workspace receiver),
subscribe to Push events, paste this generated secret".
- That per-workspace endpoint verifies HMAC with the configured secret and
routes by repo full name + installation to matching workspaces (the routing
data is in `workspace_settings`).
- GHES typically shares a network with the instance, so this works **air-gapped**
@@ -237,10 +237,8 @@ workspace + repo path — decide at implementation review).
New instance endpoints (EE):
- `POST /api/w/{workspace}/github_app/push_webhook/{repo_settings_id}`
per-repo hook receiver (managed app), HMAC-verified, returns 202.
- `POST /api/github_app/webhook` — instance-global receiver (self-managed app),
HMAC-verified, routes internally.
- `POST /api/w/{workspace}/github_app/webhook` — per-workspace hook receiver for
managed and self-managed apps (host-aware), HMAC-verified, returns 202.
- Both are unauthenticated-but-verified endpoints; rate-limited; bodies are
treated as hints only (§4).
@@ -420,9 +418,9 @@ now too, to avoid a second nag for phases 34). Update permission-purpose copy
Backend (EE):
- Receiver endpoints (§8): `POST /api/w/{workspace}/github_app/push_webhook/{id}`
(managed) and `POST /api/github_app/webhook` (instance-global, GHES), both
HMAC-verified — reuse `X-Hub-Signature-256` validation from
- Receiver endpoint (§8): `POST /api/w/{workspace}/github_app/webhook`
(per-workspace, managed and self-managed), HMAC-verified — reuse
`X-Hub-Signature-256` validation from
`windmill-trigger-http/src/http_trigger_auth.rs`; routes added to
`git_sync_ee.rs` `workspaced_service` / `global_service`.
- Webhook create/delete via installation token — reuse the REST pattern from