Skip to main content

fraiseql_webhooks/signature/
gitlab.rs

1//! GitLab webhook signature verification.
2//!
3//! Format: Plain token in X-Gitlab-Token header
4
5use crate::{
6    signature::{SignatureError, constant_time_eq},
7    traits::SignatureVerifier,
8};
9
10/// Verifies GitLab webhook signatures using constant-time token comparison.
11///
12/// GitLab sends the configured secret token directly in the `X-Gitlab-Token` header.
13/// No HMAC computation is involved; the header value is compared against the secret
14/// using constant-time equality to prevent timing attacks.
15pub struct GitLabVerifier;
16
17impl SignatureVerifier for GitLabVerifier {
18    fn name(&self) -> &'static str {
19        "gitlab"
20    }
21
22    fn signature_header(&self) -> &'static str {
23        "X-Gitlab-Token"
24    }
25
26    fn verify(
27        &self,
28        _payload: &[u8],
29        signature: &str,
30        secret: &str,
31        _timestamp: Option<&str>,
32        _url: Option<&str>,
33    ) -> Result<bool, SignatureError> {
34        if secret.is_empty() {
35            return Err(SignatureError::Crypto(
36                "GitLab webhook token must not be empty".to_string(),
37            ));
38        }
39        // GitLab uses a simple token comparison
40        Ok(constant_time_eq(signature.as_bytes(), secret.as_bytes()))
41    }
42}
43
44#[cfg(test)]
45mod tests {
46    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
47
48    use super::*;
49
50    const VERIFIER: GitLabVerifier = GitLabVerifier;
51    const PAYLOAD: &[u8] = b"{\"object_kind\":\"push\"}";
52
53    /// A valid token in the header must be accepted.
54    #[test]
55    fn test_valid_token_accepted() {
56        let secret = "super-secret-token";
57        let result = VERIFIER.verify(PAYLOAD, secret, secret, None, None);
58        assert!(result.unwrap(), "matching token must return true");
59    }
60
61    /// A wrong token must be rejected (returns false, not an error).
62    #[test]
63    fn test_wrong_token_rejected() {
64        let result = VERIFIER.verify(PAYLOAD, "wrong-token", "correct-token", None, None);
65        assert!(!result.unwrap(), "non-matching token must return false");
66    }
67
68    /// An empty secret must return an error (misconfiguration guard).
69    #[test]
70    fn test_empty_secret_returns_error() {
71        let result = VERIFIER.verify(PAYLOAD, "some-token", "", None, None);
72        assert!(result.is_err(), "empty secret must return an error");
73    }
74
75    /// Tokens that differ only in length must be rejected (no padding attack).
76    #[test]
77    fn test_prefix_match_rejected() {
78        // "secret" is a prefix of "secret-extra" — must not accept
79        let result = VERIFIER.verify(PAYLOAD, "secret", "secret-extra", None, None);
80        assert!(!result.unwrap(), "prefix match must not be accepted");
81    }
82
83    /// Payload content is irrelevant — GitLab token auth ignores the body.
84    #[test]
85    fn test_payload_ignored() {
86        let secret = "my-token";
87        let r1 = VERIFIER.verify(b"payload-a", secret, secret, None, None).unwrap();
88        let r2 = VERIFIER.verify(b"payload-b", secret, secret, None, None).unwrap();
89        assert!(r1 && r2, "result must not depend on payload content");
90    }
91}