Skip to content

Encrypt the project webhook secret and issue it through the API - #379

Merged
haksungjang merged 3 commits into
mainfrom
er22a-webhook-secret-encrypted
Sep 5, 2026
Merged

Encrypt the project webhook secret and issue it through the API#379
haksungjang merged 3 commits into
mainfrom
er22a-webhook-secret-encrypted

Conversation

@haksungjang

Copy link
Copy Markdown
Contributor

This product already decided that a webhook shared secret is encrypted at rest. github_app_credentials.webhook_secret_encrypted has been Fernet ciphertext since 0019. projects.webhook_secret held the same class of value in a plaintext VARCHAR(64), and both let whoever holds them forge a delivery this deployment accepts. This finishes applying the existing judgement rather than introducing a new policy.

Encryption rather than hashing, because verification needs the value back: GitHub's HMAC is recomputed from it and GitLab's token is compared against it.

Migrations 0084 (add column), 0085 (encrypt what is stored), 0086 (drop the plaintext column). 0085 carries existing values over instead of regenerating them. A new secret would not match what the SCM holds, and the symptom of that mismatch is only that scans stop starting, which is the shape of the ER70 defect this same page produced once already.

The column can no longer be written by hand, so activation had to move out of the operator SQL the guide handed out. POST /v1/projects/{project_id}/webhook-secret issues the secret and returns it once; provider is required, because a secret with no provider matches nothing in the gateway's lookup. GET /v1/projects/{project_id}/webhook reports whether one is set without returning it. Both are team_admin, matching the level that edits the project's other settings.

At delivery time the ciphertext is decrypted through one helper, so a secret that will not decrypt is refused exactly as a bad signature is. That keeps the status-code oracle closed while the log distinguishes the two causes: a deployment whose key changed needs re-issuing, a project that was never activated needs somebody to finish setting it up.

Verification

Migrations were run against a database seeded at 0083 with one project holding a plaintext secret and one holding none. The backfill log reported one row; the ciphertext was then read out and decrypted, and it matched the original value. Re-running 0085 reported zero rows and left the ciphertext unchanged.

Three mutations were planted and each was confirmed applied before the run. Dropping the provider assignment fails 5 tests; storing the plaintext instead of the ciphertext fails 5; returning the unreadable ciphertext instead of refusing fails 1. The last of those had no coverage until it was written, which is why it is a separate file.

Full backend suite (tests/unit tests/integration) on a clean database: 9253 passed, 15 skipped. mypy clean across 922 files, ruff clean, ko-style 0 findings, i18n:check in sync.

Also changed

The Integrations page told users the shared secret was "set in the project's Settings tab", which does not exist and did not exist before this change either; the guide said operator SQL. Both locales now describe the endpoint.

tests/unit/openapi_endpoints.json gains the two routes and nothing else. permission-matrix.json declares their gates.

projects.webhook_secret held the shared secret in plaintext while
github_app_credentials.webhook_secret_encrypted, the same class of value,
has been Fernet ciphertext since 0019. Migrations 0084-0086 expand, carry
the existing values over, and drop the plaintext column. Existing secrets
are re-encrypted rather than regenerated: the SCM holds the same value and
a new one would refuse every delivery with no symptom but stalled scans.

The column can no longer be written by hand, so activation moves out of the
operator SQL the guide used to hand out and into
POST /v1/projects/{id}/webhook-secret, which returns the plaintext once.
provider is required there, since a secret without one matches nothing in
the gateway lookup. GET /v1/projects/{id}/webhook reports configured state
without the value.

Verification decrypts at delivery time; an unreadable secret is refused the
way a bad signature is and logged distinctly, so a changed encryption key is
tellable from a webhook nobody finished setting up.
`kind=http` is not in VALID_KINDS. The block is an HTTP fence, which the
linter counts as executable and so requires an annotation, but a POST with a
body and a bearer token is not the GET-with-url shape `kind=api` runs. It is
`kind=manual`, matching what the page asks a person to do.
Declaring the two routes in permission-matrix.json is what made
test_viewer_matrix consider them: it classifies role-gated routes, and a
route with no matrix row was not one. Both are denied to viewer. Reading
whether a webhook is configured is part of the same setting as issuing it,
and issuing invalidates the current secret.
@haksungjang
haksungjang merged commit 83e161e into main Sep 5, 2026
32 checks passed
@haksungjang
haksungjang deleted the er22a-webhook-secret-encrypted branch September 5, 2026 02:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant