Draft: Feat(webhooks): add replay attack protection

What does this MR do?

Adds replay attack protection for webhooks. When enabled, GitLab includes cryptographic headers that allow receivers to verify that a webhook request is authentic, untampered, and fresh.

New headers

When replay protection is enabled on a webhook, GitLab adds two new headers to every request:

Header Purpose Example
X-Gitlab-Timestamp Unix timestamp when the request was created 1738512345
X-Gitlab-Signature HMAC-SHA256 signature to verify authenticity sha256=7f2e...

Important security behavior

When replay protection is enabled, the X-Gitlab-Token header is no longer sent in plaintext. The signature alone proves the sender knows the secret token. This prevents an attacker who intercepts a request from extracting the token and forging new valid signatures.

Why is this needed?

The problem: Replay attacks

Without replay protection, an attacker who intercepts a valid webhook request can re-send it later to trigger unintended actions. The existing X-Gitlab-Token header doesn't help because:

  • It's a static value that never changes between requests
  • It's sent in plaintext in every request, so an attacker who intercepts one request has everything needed to replay it indefinitely

Example attack scenario (without protection):

  1. GitLab sends a webhook for a merge_request event to trigger a deployment
  2. An attacker intercepts this request (e.g., via compromised logs or network sniffing)
  3. Days later, the attacker re-sends the exact same request
  4. The receiver validates the X-Gitlab-Token, which is still valid
  5. The deployment is triggered again unexpectedly

The solution: Timestamp + Signature

The core idea is based on HMAC (Hash-based Message Authentication Code). Here's how it works:

How the signature is built

Both GitLab and the receiver share a secret token that is configured once during webhook setup (copy & paste into both systems). This token is never sent over the network when replay protection is enabled.

GitLab builds the signature like this:

Signature = HMAC-SHA256(
    Key:  secret token (known only to GitLab and the receiver)
    Data: timestamp + "." + webhook_uuid + "." + request_body
)

Think of HMAC like a mixer: you put in the data (timestamp, UUID, body) together with a secret ingredient (the token). Only someone who knows the secret ingredient can produce the exact same result. An attacker can see the output (the signature) but cannot reverse-engineer the secret ingredient or produce a valid signature for modified data.

What this protects against

Attack Protected? How
Replay after 5+ minutes Yes Receiver rejects requests with stale timestamps
Forging a new timestamp Yes Attacker can't compute a valid signature without the secret token
Tampering with the body Yes Any change to the body invalidates the signature
Replay within 5 minutes ⚠️ Partially Receiver should track X-Gitlab-Webhook-UUID to reject duplicates

Same attack scenario WITH protection

  1. GitLab sends a webhook with X-Gitlab-Timestamp: 1738512345 and X-Gitlab-Signature: sha256=abc...
  2. The X-Gitlab-Token is not included in the request (the signature replaces it)
  3. Attacker intercepts and replays the request days later
  4. Receiver checks: current_time - 1738512345 > 300 secondsRequest rejected as stale
  5. Even if replayed within 5 minutes: receiver checks UUID against a seen-list → Duplicate rejected
  6. Attacker tries to change the timestamp to bypass the check → Signature becomes invalid (attacker doesn't know the secret token)

Feature availability

  • Project Hooks: UI + API
  • Group Hooks: UI + API
  • System Hooks: UI + API

Database changes

  • Adds enable_replay_protection boolean column to web_hooks table (default: false)

Closes #587536

Screenshots or screen recordings

Before After
image image image

How to set up and validate locally

1. Create a webhook with replay protection enabled

Via UI

  1. Go to Project/Group Settings → Webhooks (or Admin → System Hooks)
  2. Enter a URL (e.g., https://webhook.site/your-id or any request catcher)
  3. Set a Secret token (required for signature generation)
  4. Check Enable replay attack protection
  5. Save the webhook

Via API

The enable_replay_protection parameter works for all webhook types. Example for a project hook:

curl --request POST \
  --header "PRIVATE-TOKEN: <your-token>" \
  --header "Content-Type: application/json" \
  --data '{
    "url": "https://webhook.site/your-id",
    "token": "your-secret-token",
    "enable_replay_protection": true,
    "push_events": true
  }' \
  "http://gdk.local:3000/api/v4/projects/<project-id>/hooks"

For group hooks, use /api/v4/groups/<group-path>/hooks. For system hooks, use /api/v4/hooks.

2. Trigger the webhook

Push to the repository, or trigger manually via Rails console:

hook = ProjectHook.find(<hook-id>)
hook.execute({ test: 'replay-protection' }, 'push_hooks')

3. Verify the headers

In your request catcher, confirm:

  • X-Gitlab-Timestamp and X-Gitlab-Signature are present
  • X-Gitlab-Token is NOT present (this is expected when replay protection is enabled)
Header Example Value
X-Gitlab-Timestamp 1738512345
X-Gitlab-Signature sha256=82335a5d0b6028e1217a210c7612b153d8dc03abba18d3bf045666239459333b
X-Gitlab-Webhook-UUID ea15f344-d99f-4e48-9096-220ab1631d99

4. Validate the signature

Verify the signature is correctly computed (Rails console):

timestamp = "<X-Gitlab-Timestamp>"
webhook_uuid = "<X-Gitlab-Webhook-UUID>"
body = '<request body JSON>'
token = "your-secret-token"

payload = "#{timestamp}.#{webhook_uuid}.#{body}"
expected = "sha256=#{OpenSSL::HMAC.hexdigest('sha256', token, payload)}"

puts "Expected:  #{expected}"
puts "Received:  <X-Gitlab-Signature>"
# These should match

5. Verify UI badge

Go to the webhooks list page. Webhooks with replay protection enabled should display a "Replay Protection: enabled" badge next to the SSL verification badge.

How receivers can verify signatures

# Ruby example
def verify_webhook(request, secret_token)
  timestamp = request.headers['X-Gitlab-Timestamp']
  webhook_uuid = request.headers['X-Gitlab-Webhook-UUID']
  signature = request.headers['X-Gitlab-Signature']
  body = request.body.read

  # 1. Reject if timestamp is older than 5 minutes
  return false if (Time.now.to_i - timestamp.to_i).abs > 300

  # 2. Reject if UUID was already seen (prevents replay within 5 min window)
  return false if already_processed?(webhook_uuid)
  mark_as_processed(webhook_uuid)

  # 3. Compute and compare signature
  payload = "#{timestamp}.#{webhook_uuid}.#{body}"
  expected = "sha256=#{OpenSSL::HMAC.hexdigest('sha256', secret_token, payload)}"

  ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end

MR acceptance checklist

  • Tests added for new functionality
  • Documentation added
  • API support for all webhook types
  • Feature works without feature flag (opt-in per webhook)
  • Secret token (X-Gitlab-Token) is not sent in plaintext when replay protection is enabled
  • Tests verify token is excluded when replay protection is active
Edited by Norman Debald

Merge request reports

Loading