Webhooks signés
Source: tutorial/11_signed_webhooks See in Playground
Un serveur HTTP qui reçoit des webhooks de GitHub et de GitLab, et n’accepte que les requêtes qui prouvent en provenir. GitHub signe chaque corps avec un secret partagé et envoie la signature HMAC-SHA256 dans X-Hub-Signature-256 ; GitLab envoie un jeton partagé dans X-Gitlab-Token. Les requêtes acceptées reçoivent 200 accepted et le nom de leur événement est journalisé ; toute autre requête reçoit 403 rejected.
Cette étape introduit les secrets : les deux valeurs partagées sont des paramètres Secret<string>, et le programme vérifie les requêtes à partir d’elles sans jamais détenir leur valeur sous forme de donnée ordinaire.
Nécessite un port TCP libre (8080 par défaut), ainsi que curl et openssl pour envoyer des requêtes signées.
Exécution
cd tutorial/11_signed_webhooks
MELODIUM_SECRET_GITHUB_WEBHOOK_SECRET=gh-shared MELODIUM_SECRET_GITLAB_WEBHOOK_TOKEN=gl-token \
melodium run Compo.tomlDans un second terminal :
BODY='{"ref":"main"}'
SIGNATURE=$(printf %s "$BODY" | openssl dgst -sha256 -hmac gh-shared | awk '{print $NF}')
curl -X POST http://127.0.0.1:8080/github -H "X-GitHub-Event: push" -H "X-Hub-Signature-256: sha256=$SIGNATURE" -d "$BODY"
# accepted
curl -X POST http://127.0.0.1:8080/github -H "X-GitHub-Event: push" -H "X-Hub-Signature-256: sha256=00ff" -d "$BODY"
# rejected
curl -X POST http://127.0.0.1:8080/gitlab -H "X-Gitlab-Event: Push Hook" -H "X-Gitlab-Token: gl-token" -d "$BODY"
# accepted
curl -X POST http://127.0.0.1:8080/gitlab -H "X-Gitlab-Event: Push Hook" -H "X-Gitlab-Token: wrong" -d "$BODY"
# rejectedLe journal du serveur affiche :
info: github: push
warning: github: invalid signature, request rejected
info: gitlab: Push Hook
warning: gitlab: invalid token, request rejectedPour recevoir de vrais webhooks, le serveur doit être joignable depuis GitHub ou GitLab (derrière un proxy inverse ou un tunnel), avec le même secret configuré dans les paramètres de webhook du dépôt.
Optionnel : ajoutez --api-report et un jeton d’API (MELODIUM_API_TOKEN) pour voir la trace complète de cette exécution sur Cadence.CI .
Fonctionnement
main reçoit les deux valeurs partagées sous forme de secrets, et déclare un unique HttpServer pour les deux routes :
treatment main(
const github_secret: Secret<string> = "env:MELODIUM_SECRET_GITHUB_WEBHOOK_SECRET",
const gitlab_token: Secret<string> = "env:MELODIUM_SECRET_GITLAB_WEBHOOK_TOKEN",
const port: u16 = 8080
)
model server: HttpServer(host=|from_ipv4(|localhost_ipv4()), port=port, secret_headers="X-Gitlab-Token")
{
startup()
start[http_server=server]()
startup.trigger -> start.trigger
github[http_server=server](secret=github_secret)
gitlab[http_server=server](token=gitlab_token)
}github_secret et gitlab_token ont pour valeurs par défaut des localisateurs, env:MELODIUM_SECRET_GITHUB_WEBHOOK_SECRET et env:MELODIUM_SECRET_GITLAB_WEBHOOK_TOKEN. Les programmes n’ont accès qu’aux variables d’environnement dont le nom commence par MELODIUM_SECRET_, et les valeurs ne sont révélées qu’à l’intérieur des traitements de crypto qui les vérifient : elles n’apparaissent donc dans aucun paramètre, journal ni rapport. Un autre localisateur peut être donné en ligne de commande à la place, comme --github_secret file:/run/secrets/github_webhook.
Les deux routes suivent la même forme :
Vérifier une signature HMAC
POST /github transmet l’en-tête de signature et le corps à crypto/signature/hmac::verify, avec le secret partagé comme clé :
treatment github[http_server: HttpServer](const secret: Secret<string>)
{
connection[http_server=http_server](method=|post(), route="/github")
signature: getHeader(key="X-Hub-Signature-256")
verify(key=secret, prefix="sha256=")
connection.headers -> signature.map,value -> verify.signature
connection.data ----------------------------> verify.data
respond()
rejected: one<void>()
verify.valid ------------------> respond.accepted
verify.invalid -> rejected.a
verify.failed --> rejected.b,value -> respond.rejected
respond.status --> connection.status
respond.headers -> connection.headers
respond.data ----> connection.data
event: getHeader(key="X-GitHub-Event")
eventName: unwrapOr<string>(default="unknown")
onceValid: releaseBlock<string>()
logEvent: logInfo(label="github")
connection.headers -> event.map,value -> eventName.option,value -> onceValid.data,released -> logEvent.message
verify.valid -------------------------------------------------> onceValid.leverage
/* … journaux d'avertissement et d'erreur … */
}verify calcule le HMAC du corps au fil de son arrivée, et le compare en temps constant avec la signature une fois le corps terminé. Un en-tête absent, un en-tête sans le préfixe sha256=, ou une signature qui n’est pas un hexadécimal valide, comptent tous comme invalid. Le statut n’est décidé qu’une fois la vérification terminée : aucune requête ne reçoit donc 200 avant que sa signature soit reconnue comme bonne.
invalid et failed mènent tous deux à 403, réunis par one. failed signifie que le secret lui-même n’a pas pu être révélé (par exemple une variable d’environnement absente), et sa raison part dans le journal d’erreurs, jamais vers le client.
Vérifier un jeton reçu comme en-tête secret
GitLab envoie son jeton dans un en-tête, et un jeton est aussi sensible du côté qui le reçoit que du côté qui l’envoie. HttpServer(..., secret_headers="X-Gitlab-Token") le déclare comme en-tête secret : il est retiré des en-têtes ordinaires de la requête, et n’est donné que dans la table secret_headers de @HttpRequest, sous forme de Secret<string>. Authorization et Cookie sont toujours traités ainsi.
treatment receivedToken()
require @HttpRequest
input trigger: Block<void>
output token: Block<Option<Secret<string>>>
{
emitToken: emit<Option<Secret<string>>>(value=|get<Secret<string>>(@HttpRequest[secret_headers], "X-Gitlab-Token"))
Self.trigger -> emitToken.trigger,emit -> Self.token
}crypto/token::check compare ensuite le jeton reçu avec celui configuré, en temps constant, sans révéler ni l’un ni l’autre au programme :
receivedToken()
checkToken(token=token)
connection.started -> receivedToken.trigger,token -> checkToken.receivedUn jeton reçu de cette façon ne peut pas être journalisé ni renvoyé par erreur : ce n’est jamais une donnée ordinaire.
Ne journaliser que les événements acceptés
Le nom de l’événement est lu depuis X-GitHub-Event ou X-Gitlab-Event dès l’arrivée de la requête, mais releaseBlock le retient jusqu’à ce que valid soit émis. La ligne de journal n’existe que pour les requêtes acceptées : quand une requête est rejetée, valid n’arrive jamais et la valeur retenue est abandonnée.
Un seul traitement de réponse pour toutes les routes
respond reçoit les deux issues Block<void> et alimente les trois sorties dont connection a besoin (status, headers, data), avec one<T>() pour réunir les deux valeurs possibles de chacune :
treatment respond()
input accepted: Block<void>
input rejected: Block<void>
output status: Block<HttpStatus>
output headers: Block<StringMap>
output data: Stream<byte>
{
okStatus: emit<HttpStatus>(value=|ok())
forbiddenStatus: emit<HttpStatus>(value=|forbidden())
status: one<HttpStatus>()
Self.accepted -> okStatus.trigger,emit -----> status.a
Self.rejected -> forbiddenStatus.trigger,emit -> status.b,value -> Self.status
answered: check<HttpStatus>()
emitHeaders: emit<StringMap>(value=|map([]))
status.value -> answered.value,check -> emitHeaders.trigger,emit -> Self.headers
acceptedText: emit<string>(value="accepted\n")
rejectedText: emit<string>(value="rejected\n")
text: one<string>()
asStream: stream<string>()
encode()
Self.accepted -> acceptedText.trigger,emit -> text.a
Self.rejected -> rejectedText.trigger,emit -> text.b,value -> asStream.block,stream -> encode.text,data -> Self.data
}Dépendances
[dependencies]
std = "0.11.0" # flux de base, journalisation, structures de données
http = "0.11.0" # serveur HTTP et en-têtes secrets
net = "0.11.0" # utilitaires d'adresses IP
encoding = "0.11.0" # encodage / décodage UTF-8
crypto = "0.11.0" # signatures HMAC et vérification de jetons