Diagnostic GitHub API
the hook sends form-encoded bodies to a JSON receiver
The handler is written, deployed and subscribed to the right event. GitHub says the delivery succeeded. Your receiver says it got a request and found nothing in it — or says nothing at all, because it returned 200 having parsed an empty object out of a body it never understood. Every field in the hook looks right, because the field that is wrong is the one nobody reads.
Read GET /repos/{owner}/{repo}/hooks and look at config.content_type. There are two values: json, which posts the event as an application/json body, and form, which posts application/x-www-form-urlencoded with the entire event JSON stuffed into a single payload= field. form is the default, so a hook created without naming the value is a form hook, and the field is absent from config rather than set to anything you would notice.
Confirm it on a real delivery: GET /repos/{owner}/{repo}/hooks/{hook_id}/deliveries/{delivery_id} returns request.headers, and the content-type there is what GitHub actually sent. The body it recorded shows the same thing from the other side — a form delivery arrives as one payload key holding a JSON string, not as the event object your handler expects. What no API read can tell you is what your receiver parses, so the script takes that from you and says which half it measured and which half it was told.
The problem in plain words
The symptom depends entirely on which web framework is underneath, which is why two teams with the same misconfiguration describe completely different bugs. A strict JSON parser refuses the body and the delivery is recorded with a 400, which at least points somewhere. A tolerant one shrugs, hands the handler an empty dictionary or a dictionary with one strange key in it, and the handler falls through every branch it has and returns 200. GitHub records a successful delivery. Your metrics record a successful request. Nothing anywhere records that the event was dropped.
That second case is what makes this expensive. The existing advice for a webhook that is not working is to read the delivery log, and the delivery log is green. So the investigation moves on to the things that are usually wrong: is the hook subscribed to the event, is the URL right, is the receiver deployed, is there a second hook somewhere sending to the old host. All of those get checked, all of them come back clean, and the check that would have taken one line never happens because content_type is not a field anybody has ever had a reason to look at.
Then somebody adds a log line to the receiver, sees a request body starting payload=%7B%22action%22%3A, and the whole thing resolves in a minute. The second half of the repair is the part that catches people out afterwards. Signature verification is computed over the exact bytes of the request, and on a form hook those bytes are the urlencoded wrapper. A receiver that url-decodes first, or that re-serialises the parsed object and hashes that, has been verifying nothing meaningful; switching the hook to json changes the bytes and can turn a passing signature check into a failing one on the same deploy that fixes the parse.
Why it happens
Form is the default, and defaults are invisible. A hook created through the API without a content_type is a form hook, and config comes back without the key rather than with "form" in it. Anything auditing the hook by eye sees a URL, a secret and an events array, all correct, and no field at all where the problem is. The web UI shows a dropdown with the same default, already collapsed.
The event is not the body, it is a field inside the body. A form delivery is a single urlencoded parameter, payload, whose value is the JSON document as a string. Your handler is not receiving a slightly different shape of the same object; it is receiving a different document entirely, one level of encoding out. That is why nothing partially works: no field your code reads exists at the top level of what arrived.
A 200 is not evidence of anything here. Many frameworks answer a body they cannot parse with a default value rather than an exception, and the handler then does nothing successfully. This is the specific reason this note exists next to the delivery failure audit: that audit groups attempts by status code and needs a non-OK status to find anything. Here the status is fine and the finding is in the request half of the record, not the response half.
The signature is over the bytes, whichever bytes they are. X-Hub-Signature-256 is the HMAC of the raw request body. On a form hook that is payload= followed by the percent-encoded document. A verifier written against the raw bytes is correct in both worlds and survives the change. A verifier that parses first and hashes a re-serialisation is broken in both worlds, and only finds out when the encoding moves. Fix the encoding and the verifier in the same change, in that order, and redeliver one event to check.
The receiver's parser is not readable from here. This is the section's standing blind spot: the API describes what GitHub sends and never what you do with it. The script reads the configured encoding, and it reads the real content-type header and the recorded body shape from a sample of deliveries, so the sending half is measured rather than assumed. The receiving half is a flag you set, and the output labels it as declared rather than observed.
The fix, as a flow
Three kinds of GET and one judgement call. The hook list gives the configured encoding, and an absent field is resolved to form rather than to unknown, because that is the documented default and the most common way this happens. The delivery list gives the recent statuses, and the individual records give the only thing that settles it: the content-type header GitHub really sent, and a body recorded as one payload key holding a string. What the receiver does with any of that is declared by the caller and labelled as declared.
How to fix it
Read the field nobody reads
GET /repos/{owner}/{repo}/hooks and look at config.content_type on each hook. Treat an absent key as form, because that is what it means. This one read is usually the whole diagnosis, and it costs a single request against a quota you can check for free.
Confirm on a delivery rather than trusting the config
List deliveries with GET /repos/{owner}/{repo}/hooks/{hook_id}/deliveries, then fetch a handful of individual records. Only the individual record carries request.headers and request.payload. The content-type header is what GitHub sent; a body recorded as a single payload key holding a string is the wrapper, seen from the receiving end.
Say what your receiver actually parses
The script takes --receiver json or --receiver form, because it cannot read your code. Answer it from the framework, not from intent: a handler that reads request.json, await req.json() or json.loads(body) is a JSON receiver whatever the comment above it says. If you genuinely do not know, leave it out and the script reports the encoding and the risk without claiming a mismatch.
Look for the parse statuses, and do not rely on them
Count how many recent deliveries came back 400, 415 or 422. A run of those is a strict parser refusing the body, and it confirms the finding immediately. Zero of them confirms nothing at all, because the tolerant case answers 200. The script reports the count as corroboration and never as the test.
Change the encoding and the verifier together
Set config.content_type to json, and in the same change make signature verification read the raw request bytes before any parsing happens. Then redeliver one event from the delivery log and watch it land. Doing only the first half moves a receiver that was quietly dropping events to one that rejects them loudly, which is progress but is not a working integration.
How to check it worked
The check is cheap enough to keep in a deploy script, and it answers the same way whether or not anything has failed.
GITHUB_TOKEN=$RO_TOKEN python3 github_hook_content_type.py --repo acme/payments --receiver json
# hook 4218871 https://hooks.example.com/github content_type=form (default, key absent)
# deliveries sampled: 5, form content-type header on 5, payload= wrapper on 5
# parse statuses (400/415/422): 0 of 40 recent deliveries
# form-to-json: the hook sends application/x-www-form-urlencoded and the receiver
# was declared as JSON. Every event arrives wrapped in a payload= field.
# repair: set config.content_type to json, and verify the signature over the raw
# request bytes before parsing, in the same change.
# after the hook is switched to json
# consistent-json: the hook sends application/json and the receiver parses JSON.
The full code
Everything that decides is pure, and everything impure is three GETs. The one judgement call in the whole script is what to do with an absent content_type, and it resolves it to form rather than to unknown, because that is the documented default and the single most common way this happens. The verdict function never lets a clean status code argue the finding away, and it keeps what it read separate from what it was told.
"""Say whether a webhook sends a body encoding its receiver cannot read.
Read only. Three kinds of GET: the hook list, for config.content_type; the
delivery list, for recent attempts; and a few individual delivery records, which
are the only place the request headers and the recorded body appear. Nothing is
created, edited or redelivered.
config.content_type defaults to form, which wraps the event JSON inside a
urlencoded payload= field. A receiver written for application/json either
rejects that body or, much more expensively, accepts it and parses nothing while
answering 200.
The API cannot see what your receiver parses. That half is declared with
--receiver and the output says which half was measured and which was told.
Environment:
GITHUB_TOKEN a read-only token with access to the repository
"""
import argparse
import json
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_hook_content_type")
API = "https://api.github.com"
UA = "github-hook-content-type/1.0"
FORM = "form"
JSON = "json"
# The documented default. A hook created without naming a content type is a form
# hook, and config comes back with the key absent rather than set.
DEFAULT_CONTENT_TYPE = FORM
# The statuses a strict parser answers a body it cannot read with. Corroboration
# only: the tolerant frameworks answer 200 and this count stays at zero.
PARSE_STATUSES = (400, 415, 422)
def content_type_of(config):
"""The hook's configured body encoding, normalised. Pure.
An absent key is not unknown, it is form. Reporting it as unknown would lose
the most common way a hook ends up form-encoded, which is nobody choosing.
"""
if not isinstance(config, dict):
return "unknown"
raw = config.get("content_type")
if raw is None:
return DEFAULT_CONTENT_TYPE
value = str(raw).strip().lower()
if value in ("json", "application/json"):
return JSON
if value in ("form", "application/x-www-form-urlencoded"):
return FORM
return "unknown"
def content_type_was_explicit(config):
"""Whether the hook names its encoding or inherits the default. Pure."""
return isinstance(config, dict) and config.get("content_type") is not None
def header_of(headers, name):
"""One header from a delivery record, case-insensitively. Pure."""
if not isinstance(headers, dict):
return None
wanted = str(name).strip().lower()
for key, value in headers.items():
if str(key).strip().lower() == wanted:
return value
return None
def encoding_of_header(value):
"""Classify a content-type header value, ignoring parameters. Pure."""
if value is None:
return "unknown"
text = str(value).split(";")[0].strip().lower()
if text == "application/json":
return JSON
if text == "application/x-www-form-urlencoded":
return FORM
return "unknown"
def delivery_encoding(delivery):
"""What GitHub said it was sending on one delivery record. Pure."""
if not isinstance(delivery, dict):
return "unknown"
request = delivery.get("request")
if not isinstance(request, dict):
return "unknown"
return encoding_of_header(header_of(request.get("headers"), "content-type"))
def is_form_wrapped(payload):
"""Whether a recorded body is the payload= wrapper rather than the event. Pure.
A form delivery records one key, payload, holding the event JSON as a string.
An event object has many keys and none of that shape, so this is unambiguous
where it fires and simply silent where the record has been normalised.
"""
if not isinstance(payload, dict):
return False
return list(payload.keys()) == ["payload"] and isinstance(payload.get("payload"), str)
def wrapper_evidence(details):
"""Count the delivery records showing the form wrapper, both ways. Pure."""
records = [d for d in (details or []) if isinstance(d, dict)]
by_header = sum(1 for d in records if delivery_encoding(d) == FORM)
by_body = sum(1 for d in records
if is_form_wrapped((d.get("request") or {}).get("payload")))
return {"sampled": len(records), "form_header": by_header, "form_wrapper": by_body}
def parse_failures(deliveries):
"""How many recent attempts came back with a body-parse status. Pure."""
records = [d for d in (deliveries or []) if isinstance(d, dict)]
hits = 0
for d in records:
try:
code = int(d.get("status_code"))
except (TypeError, ValueError):
continue
if code in PARSE_STATUSES:
hits += 1
return hits, len(records)
def receiver_of(declared):
"""Normalise what the caller says the receiver parses. Pure."""
value = str(declared or "unknown").strip().lower()
return value if value in (JSON, FORM) else "unknown"
def verdict(hook_encoding, declared, evidence=None, failures=0, sampled_total=0):
"""Turn the configured encoding and the declared receiver into a finding. Pure.
The status codes never decide. A clean delivery log is the expected state of
this problem on a tolerant framework, so letting it soften the verdict would
remove the only case worth writing a script for.
"""
seen = evidence or {}
confirmed = max(int(seen.get("form_header") or 0), int(seen.get("form_wrapper") or 0))
parsed = receiver_of(declared)
corroboration = ""
if confirmed:
corroboration = (" %d of %d sampled deliveries carry the form encoding."
% (confirmed, seen.get("sampled") or 0))
if failures:
corroboration += (" %d of %d recent attempts came back 400, 415 or 422."
% (failures, sampled_total))
if hook_encoding == "unknown":
return ("encoding-unknown",
"config.content_type holds a value this script does not "
"recognise. GitHub supports json and form; anything else needs "
"reading by hand before the rest of this is meaningful.")
if hook_encoding == FORM and parsed == JSON:
return ("form-to-json",
"the hook sends application/x-www-form-urlencoded and the "
"receiver was declared as JSON. Every event arrives wrapped in "
"a payload= field, so no key your handler reads exists at the "
"top level of the body." + corroboration)
if hook_encoding == JSON and parsed == FORM:
return ("json-to-form",
"the hook sends application/json and the receiver was declared "
"as a form parser. The body has no payload= field to unwrap, so "
"the parsed result is empty rather than wrong.")
if hook_encoding == FORM and parsed == "unknown":
return ("receiver-undeclared",
"the hook sends application/x-www-form-urlencoded, which is the "
"default rather than a decision. No receiver was declared, so "
"this is a risk rather than a finding: confirm the handler "
"unwraps the payload field before treating it as healthy."
+ corroboration)
if hook_encoding == FORM:
return ("consistent-form",
"the hook sends application/x-www-form-urlencoded and the "
"receiver was declared as a form parser. Consistent, but the "
"signature covers the urlencoded wrapper, so verify over the raw "
"bytes rather than over anything you unwrapped.")
return ("consistent-json",
"the hook sends application/json and the receiver parses JSON.")
def repair(state):
"""The sentence a reader has to act on. Pure."""
if state == "form-to-json":
return ("set config.content_type to json on the hook, and in the same "
"change make signature verification read the raw request bytes "
"before parsing. Then redeliver one event from the delivery log "
"and confirm the handler ran.")
if state == "json-to-form":
return ("parse the body as JSON in the receiver. Changing the hook back "
"to form to suit the parser is the wrong direction: form is the "
"legacy encoding and it makes signature verification harder.")
if state == "receiver-undeclared":
return ("run this again with --receiver set from the handler code. If "
"the handler reads the body as JSON, this is a live finding; if "
"it unwraps the payload field first, it is working as built.")
if state == "consistent-form":
return ("nothing urgent. Moving to json is still worth doing, because "
"it removes a layer of encoding between the signature and the "
"document you verify.")
if state == "encoding-unknown":
return ("read config.content_type by hand. Only json and form are "
"supported values and neither of them is what is set here.")
return "nothing."
def get(session, path):
"""One GET. Returns (status, json-or-None)."""
url = API + path if path.startswith("/") else path
r = session.get(url, timeout=30)
try:
body = r.json()
except ValueError:
body = None
return r.status_code, body
def hooks_for(session, repo):
"""Every hook on the repository."""
status, body = get(session, "/repos/%s/hooks?per_page=100" % repo)
if status != 200 or not isinstance(body, list):
log.error("GET /repos/%s/hooks returned %d", repo, status)
return []
return body
def deliveries_for(session, repo, hook_id):
"""The recent delivery list. No request headers here; that needs the detail."""
status, body = get(session, "/repos/%s/hooks/%s/deliveries?per_page=100"
% (repo, hook_id))
if status != 200 or not isinstance(body, list):
log.info("deliveries for hook %s returned %d; the config read stands on "
"its own", hook_id, status)
return []
return body
def delivery_details(session, repo, hook_id, deliveries, sample):
"""Individual records, which are the only place request.headers appears."""
out = []
for d in deliveries[:max(0, int(sample))]:
if not isinstance(d, dict) or d.get("id") is None:
continue
status, body = get(session, "/repos/%s/hooks/%s/deliveries/%s"
% (repo, hook_id, d["id"]))
if status == 200 and isinstance(body, dict):
out.append(body)
return out
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--repo", default=os.environ.get("GITHUB_REPO"),
help="owner/name of the repository holding the hook")
ap.add_argument("--receiver", default=os.environ.get("GITHUB_RECEIVER_PARSES"),
help="what your receiver parses: json or form. The API "
"cannot see this, so it is declared rather than read")
ap.add_argument("--sample", type=int, default=5,
help="how many individual delivery records to fetch")
args = ap.parse_args()
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN to a read-only token with access to the repository")
return 2
if not args.repo:
log.error("set --repo to owner/name")
return 2
session = requests.Session()
session.headers.update({
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": UA,
})
findings = []
report = []
for hook in hooks_for(session, args.repo):
config = hook.get("config") or {}
encoding = content_type_of(config)
explicit = content_type_was_explicit(config)
log.info("hook %s %s content_type=%s (%s)", hook.get("id"),
config.get("url"), encoding,
"explicit" if explicit else "default, key absent")
deliveries = deliveries_for(session, args.repo, hook.get("id"))
details = delivery_details(session, args.repo, hook.get("id"),
deliveries, args.sample)
evidence = wrapper_evidence(details)
failures, total = parse_failures(deliveries)
log.info("deliveries sampled: %d, form content-type header on %d, "
"payload= wrapper on %d", evidence["sampled"],
evidence["form_header"], evidence["form_wrapper"])
log.info("parse statuses (400/415/422): %d of %d recent deliveries",
failures, total)
state, detail = verdict(encoding, args.receiver, evidence, failures, total)
log.info("%s: %s", state, detail)
log.info("repair: %s", repair(state))
if state in ("form-to-json", "json-to-form", "encoding-unknown"):
findings.append(hook.get("id"))
report.append({
"hook_id": hook.get("id"),
"url": config.get("url"),
"content_type": encoding,
"content_type_explicit": explicit,
"receiver_declared": receiver_of(args.receiver),
"sampled": evidence["sampled"],
"form_header_seen": evidence["form_header"],
"form_wrapper_seen": evidence["form_wrapper"],
"parse_status_count": failures,
"deliveries_examined": total,
"state": state,
"detail": detail,
"repair": repair(state),
})
print(json.dumps({"repository": args.repo, "hooks": report}, indent=2, default=str))
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Say whether a webhook sends a body encoding its receiver cannot read.
*
* Read only. Three kinds of GET: the hook list, the delivery list, and a few
* individual delivery records, which are the only place the request headers and
* the recorded body appear. Nothing is created, edited or redelivered.
*
* Environment:
* GITHUB_TOKEN a read-only token with access to the repository
* GITHUB_REPO owner/name
* GITHUB_RECEIVER_PARSES json or form, declared rather than read
*/
const API = 'https://api.github.com';
const UA = 'github-hook-content-type/1.0';
export const FORM = 'form';
export const JSON_CT = 'json';
/** The documented default: an absent key means form. */
export const DEFAULT_CONTENT_TYPE = FORM;
/** Corroboration only; a tolerant framework answers 200 and this stays empty. */
export const PARSE_STATUSES = [400, 415, 422];
/** The hook's configured body encoding, normalised. Pure. */
export function contentTypeOf(config) {
if (!config || typeof config !== 'object') return 'unknown';
const raw = config.content_type;
if (raw === null || raw === undefined) return DEFAULT_CONTENT_TYPE;
const value = String(raw).trim().toLowerCase();
if (['json', 'application/json'].includes(value)) return JSON_CT;
if (['form', 'application/x-www-form-urlencoded'].includes(value)) return FORM;
return 'unknown';
}
/** Whether the hook names its encoding or inherits the default. Pure. */
export function contentTypeWasExplicit(config) {
return Boolean(config && typeof config === 'object'
&& config.content_type !== null && config.content_type !== undefined);
}
/** One header from a delivery record, case-insensitively. Pure. */
export function headerOf(headers, name) {
if (!headers || typeof headers !== 'object') return null;
const wanted = String(name).trim().toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (String(key).trim().toLowerCase() === wanted) return value;
}
return null;
}
/** Classify a content-type header value, ignoring parameters. Pure. */
export function encodingOfHeader(value) {
if (value === null || value === undefined) return 'unknown';
const text = String(value).split(';')[0].trim().toLowerCase();
if (text === 'application/json') return JSON_CT;
if (text === 'application/x-www-form-urlencoded') return FORM;
return 'unknown';
}
/** What GitHub said it was sending on one delivery record. Pure. */
export function deliveryEncoding(delivery) {
if (!delivery || typeof delivery !== 'object') return 'unknown';
const request = delivery.request;
if (!request || typeof request !== 'object') return 'unknown';
return encodingOfHeader(headerOf(request.headers, 'content-type'));
}
/** Whether a recorded body is the payload= wrapper rather than the event. Pure. */
export function isFormWrapped(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
const keys = Object.keys(payload);
return keys.length === 1 && keys[0] === 'payload' && typeof payload.payload === 'string';
}
/** Count the delivery records showing the form wrapper, both ways. Pure. */
export function wrapperEvidence(details) {
const records = (details || []).filter((d) => d && typeof d === 'object');
const formHeader = records.filter((d) => deliveryEncoding(d) === FORM).length;
const formWrapper = records.filter((d) => isFormWrapped((d.request || {}).payload)).length;
return { sampled: records.length, form_header: formHeader, form_wrapper: formWrapper };
}
/** How many recent attempts came back with a body-parse status. Pure. */
export function parseFailures(deliveries) {
const records = (deliveries || []).filter((d) => d && typeof d === 'object');
let hits = 0;
for (const d of records) {
const code = Number(d.status_code);
if (Number.isFinite(code) && PARSE_STATUSES.includes(code)) hits += 1;
}
return [hits, records.length];
}
/** Normalise what the caller says the receiver parses. Pure. */
export function receiverOf(declared) {
const value = String(declared ?? 'unknown').trim().toLowerCase();
return [JSON_CT, FORM].includes(value) ? value : 'unknown';
}
/** Turn the configured encoding and the declared receiver into a finding. Pure. */
export function verdict(hookEncoding, declared, evidence = null, failures = 0, sampledTotal = 0) {
const seen = evidence || {};
const confirmed = Math.max(Number(seen.form_header || 0), Number(seen.form_wrapper || 0));
const parsed = receiverOf(declared);
let corroboration = '';
if (confirmed) {
corroboration = ` ${confirmed} of ${seen.sampled || 0} sampled deliveries carry the form encoding.`;
}
if (failures) {
corroboration += ` ${failures} of ${sampledTotal} recent attempts came back 400, 415 or 422.`;
}
if (hookEncoding === 'unknown') {
return ['encoding-unknown',
'config.content_type holds a value this script does not recognise. GitHub '
+ 'supports json and form; anything else needs reading by hand before the '
+ 'rest of this is meaningful.'];
}
if (hookEncoding === FORM && parsed === JSON_CT) {
return ['form-to-json',
'the hook sends application/x-www-form-urlencoded and the receiver was '
+ 'declared as JSON. Every event arrives wrapped in a payload= field, so no '
+ 'key your handler reads exists at the top level of the body.' + corroboration];
}
if (hookEncoding === JSON_CT && parsed === FORM) {
return ['json-to-form',
'the hook sends application/json and the receiver was declared as a form '
+ 'parser. The body has no payload= field to unwrap, so the parsed result '
+ 'is empty rather than wrong.'];
}
if (hookEncoding === FORM && parsed === 'unknown') {
return ['receiver-undeclared',
'the hook sends application/x-www-form-urlencoded, which is the default '
+ 'rather than a decision. No receiver was declared, so this is a risk '
+ 'rather than a finding: confirm the handler unwraps the payload field '
+ 'before treating it as healthy.' + corroboration];
}
if (hookEncoding === FORM) {
return ['consistent-form',
'the hook sends application/x-www-form-urlencoded and the receiver was '
+ 'declared as a form parser. Consistent, but the signature covers the '
+ 'urlencoded wrapper, so verify over the raw bytes rather than over '
+ 'anything you unwrapped.'];
}
return ['consistent-json', 'the hook sends application/json and the receiver parses JSON.'];
}
/** The sentence a reader has to act on. Pure. */
export function repair(state) {
if (state === 'form-to-json') {
return 'set config.content_type to json on the hook, and in the same change '
+ 'make signature verification read the raw request bytes before parsing. '
+ 'Then redeliver one event from the delivery log and confirm the handler ran.';
}
if (state === 'json-to-form') {
return 'parse the body as JSON in the receiver. Changing the hook back to '
+ 'form to suit the parser is the wrong direction: form is the legacy '
+ 'encoding and it makes signature verification harder.';
}
if (state === 'receiver-undeclared') {
return 'run this again with the receiver declared from the handler code. If '
+ 'the handler reads the body as JSON, this is a live finding; if it '
+ 'unwraps the payload field first, it is working as built.';
}
if (state === 'consistent-form') {
return 'nothing urgent. Moving to json is still worth doing, because it '
+ 'removes a layer of encoding between the signature and the document you verify.';
}
if (state === 'encoding-unknown') {
return 'read config.content_type by hand. Only json and form are supported '
+ 'values and neither of them is what is set here.';
}
return 'nothing.';
}
function headers(token) {
return {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
};
}
async function get(token, path) {
const res = await fetch(API + path, { headers: headers(token) });
let body = null;
try { body = await res.json(); } catch { body = null; }
return { status: res.status, body };
}
async function main() {
const token = process.env.GITHUB_TOKEN;
const repo = process.env.GITHUB_REPO;
if (!token || !repo) {
console.error('set GITHUB_TOKEN and GITHUB_REPO=owner/name');
process.exitCode = 2;
return;
}
const declared = process.env.GITHUB_RECEIVER_PARSES || null;
const sample = Number(process.env.GITHUB_SAMPLE || 5);
const list = await get(token, `/repos/${repo}/hooks?per_page=100`);
if (list.status !== 200 || !Array.isArray(list.body)) {
console.error(`GET /repos/${repo}/hooks returned ${list.status}`);
process.exitCode = 2;
return;
}
const report = [];
let findings = 0;
for (const hook of list.body) {
const config = hook.config || {};
const encoding = contentTypeOf(config);
console.log(`hook ${hook.id} ${config.url} content_type=${encoding} `
+ `(${contentTypeWasExplicit(config) ? 'explicit' : 'default, key absent'})`);
const dl = await get(token, `/repos/${repo}/hooks/${hook.id}/deliveries?per_page=100`);
const deliveries = dl.status === 200 && Array.isArray(dl.body) ? dl.body : [];
const details = [];
for (const d of deliveries.slice(0, sample)) {
const one = await get(token, `/repos/${repo}/hooks/${hook.id}/deliveries/${d.id}`);
if (one.status === 200 && one.body) details.push(one.body);
}
const evidence = wrapperEvidence(details);
const [failures, total] = parseFailures(deliveries);
console.log(`deliveries sampled: ${evidence.sampled}, form content-type header on `
+ `${evidence.form_header}, payload= wrapper on ${evidence.form_wrapper}`);
console.log(`parse statuses (400/415/422): ${failures} of ${total} recent deliveries`);
const [state, detail] = verdict(encoding, declared, evidence, failures, total);
console.log(`${state}: ${detail}`);
console.log(`repair: ${repair(state)}`);
if (['form-to-json', 'json-to-form', 'encoding-unknown'].includes(state)) findings += 1;
report.push({
hook_id: hook.id,
url: config.url,
content_type: encoding,
content_type_explicit: contentTypeWasExplicit(config),
receiver_declared: receiverOf(declared),
sampled: evidence.sampled,
parse_status_count: failures,
deliveries_examined: total,
state,
});
}
console.log(JSON.stringify({ repository: repo, hooks: report }, null, 2));
process.exitCode = findings ? 1 : 0;
}
// Guarded so importing this file from the test runner does not start a run and
// fail the suite even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The tests spend most of their attention on the two things that decide the answer. First, that an absent content_type resolves to form rather than to unknown, in every defensive branch, because that is the case the note exists for. Second, that a clean delivery log never turns a mismatch into a pass — a form hook feeding a JSON receiver with forty successful deliveries behind it is still the finding, and there is a test that says so in those words.
from github_hook_content_type import (
content_type_of, content_type_was_explicit, delivery_encoding,
encoding_of_header, header_of, is_form_wrapped, parse_failures, receiver_of,
repair, verdict, wrapper_evidence,
)
FORM_DELIVERY = {
"id": 1,
"status_code": 200,
"request": {
"headers": {"Content-Type": "application/x-www-form-urlencoded",
"X-GitHub-Event": "push"},
"payload": {"payload": '{ "action": "opened" }'},
},
}
JSON_DELIVERY = {
"id": 2,
"status_code": 200,
"request": {
"headers": {"content-type": "application/json; charset=utf-8"},
"payload": {"action": "opened", "number": 7},
},
}
def test_an_absent_content_type_is_form_not_unknown():
assert content_type_of({}) == "form"
assert content_type_of({"url": "https://example.com"}) == "form"
assert not content_type_was_explicit({})
assert content_type_was_explicit({"content_type": "form"})
def test_both_spellings_of_each_encoding_are_understood():
assert content_type_of({"content_type": "json"}) == "json"
assert content_type_of({"content_type": "application/json"}) == "json"
assert content_type_of({"content_type": " FORM "}) == "form"
assert content_type_of({"content_type": "application/x-www-form-urlencoded"}) == "form"
assert content_type_of({"content_type": "text/xml"}) == "unknown"
assert content_type_of(None) == "unknown"
def test_headers_are_read_case_insensitively_and_parameters_ignored():
assert header_of({"Content-Type": "application/json"}, "content-type") == "application/json"
assert header_of({"CONTENT-TYPE": "x"}, "Content-Type") == "x"
assert header_of({}, "content-type") is None
assert header_of(None, "content-type") is None
assert encoding_of_header("application/json; charset=utf-8") == "json"
assert encoding_of_header("application/x-www-form-urlencoded") == "form"
assert encoding_of_header(None) == "unknown"
def test_the_delivery_record_is_read_from_the_request_half():
assert delivery_encoding(FORM_DELIVERY) == "form"
assert delivery_encoding(JSON_DELIVERY) == "json"
assert delivery_encoding({"status_code": 200}) == "unknown"
assert delivery_encoding(None) == "unknown"
def test_the_wrapper_is_one_string_key_and_nothing_else():
assert is_form_wrapped({"payload": "{}"})
assert not is_form_wrapped({"payload": {"action": "opened"}})
assert not is_form_wrapped({"payload": "{}", "extra": 1})
assert not is_form_wrapped({"action": "opened"})
assert not is_form_wrapped(None)
def test_evidence_counts_the_header_and_the_body_separately():
ev = wrapper_evidence([FORM_DELIVERY, JSON_DELIVERY, None])
assert ev == {"sampled": 2, "form_header": 1, "form_wrapper": 1}
def test_parse_statuses_are_counted_but_only_the_three():
hits, total = parse_failures([{"status_code": 400}, {"status_code": 415},
{"status_code": 500}, {"status_code": 200},
{"status_code": None}])
assert (hits, total) == (2, 5)
def test_a_form_hook_against_a_json_receiver_is_the_finding():
state, detail = verdict("form", "json")
assert state == "form-to-json"
assert "payload= field" in detail
def test_a_clean_delivery_log_does_not_soften_the_finding():
ev = {"sampled": 5, "form_header": 5, "form_wrapper": 5}
state, detail = verdict("form", "json", ev, failures=0, sampled_total=40)
assert state == "form-to-json"
assert "5 of 5 sampled deliveries" in detail
def test_parse_statuses_are_reported_as_corroboration():
state, detail = verdict("form", "json", None, failures=12, sampled_total=40)
assert state == "form-to-json"
assert "12 of 40 recent attempts" in detail
def test_the_mirror_case_is_named_rather_than_folded_in():
assert verdict("json", "form")[0] == "json-to-form"
assert "wrong direction" in repair("json-to-form")
def test_an_undeclared_receiver_gives_a_risk_and_not_a_verdict():
state, detail = verdict("form", None)
assert state == "receiver-undeclared"
assert "risk rather than a finding" in detail
def test_consistent_pairs_are_not_findings():
assert verdict("json", "json")[0] == "consistent-json"
assert verdict("form", "form")[0] == "consistent-form"
def test_a_consistent_form_hook_is_still_warned_about_the_signature():
assert "raw bytes" in verdict("form", "form")[1]
def test_an_unrecognised_encoding_is_never_guessed_at():
state, _ = verdict("unknown", "json")
assert state == "encoding-unknown"
assert "by hand" in repair("encoding-unknown")
def test_the_receiver_flag_is_normalised_defensively():
assert receiver_of("JSON") == "json"
assert receiver_of(" form ") == "form"
assert receiver_of("maybe") == "unknown"
assert receiver_of(None) == "unknown"
def test_the_repair_always_pairs_the_encoding_with_the_verifier():
assert "raw request bytes" in repair("form-to-json")
assert repair("consistent-json") == "nothing."
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
contentTypeOf, contentTypeWasExplicit, deliveryEncoding, encodingOfHeader,
headerOf, isFormWrapped, parseFailures, receiverOf, repair, verdict,
wrapperEvidence,
} from './github-hook-content-type.mjs';
const FORM_DELIVERY = {
id: 1,
status_code: 200,
request: {
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-GitHub-Event': 'push' },
payload: { payload: '{ "action": "opened" }' },
},
};
const JSON_DELIVERY = {
id: 2,
status_code: 200,
request: {
headers: { 'content-type': 'application/json; charset=utf-8' },
payload: { action: 'opened', number: 7 },
},
};
test('an absent content_type is form, not unknown', () => {
assert.equal(contentTypeOf({}), 'form');
assert.equal(contentTypeOf({ url: 'https://example.com' }), 'form');
assert.ok(!contentTypeWasExplicit({}));
assert.ok(contentTypeWasExplicit({ content_type: 'form' }));
});
test('both spellings of each encoding are understood', () => {
assert.equal(contentTypeOf({ content_type: 'json' }), 'json');
assert.equal(contentTypeOf({ content_type: 'application/json' }), 'json');
assert.equal(contentTypeOf({ content_type: ' FORM ' }), 'form');
assert.equal(contentTypeOf({ content_type: 'application/x-www-form-urlencoded' }), 'form');
assert.equal(contentTypeOf({ content_type: 'text/xml' }), 'unknown');
assert.equal(contentTypeOf(null), 'unknown');
});
test('headers are read case insensitively and parameters ignored', () => {
assert.equal(headerOf({ 'Content-Type': 'application/json' }, 'content-type'), 'application/json');
assert.equal(headerOf({ 'CONTENT-TYPE': 'x' }, 'Content-Type'), 'x');
assert.equal(headerOf({}, 'content-type'), null);
assert.equal(headerOf(null, 'content-type'), null);
assert.equal(encodingOfHeader('application/json; charset=utf-8'), 'json');
assert.equal(encodingOfHeader('application/x-www-form-urlencoded'), 'form');
assert.equal(encodingOfHeader(null), 'unknown');
});
test('the delivery record is read from the request half', () => {
assert.equal(deliveryEncoding(FORM_DELIVERY), 'form');
assert.equal(deliveryEncoding(JSON_DELIVERY), 'json');
assert.equal(deliveryEncoding({ status_code: 200 }), 'unknown');
assert.equal(deliveryEncoding(null), 'unknown');
});
test('the wrapper is one string key and nothing else', () => {
assert.ok(isFormWrapped({ payload: '{}' }));
assert.ok(!isFormWrapped({ payload: { action: 'opened' } }));
assert.ok(!isFormWrapped({ payload: '{}', extra: 1 }));
assert.ok(!isFormWrapped({ action: 'opened' }));
assert.ok(!isFormWrapped(null));
});
test('evidence counts the header and the body separately', () => {
const ev = wrapperEvidence([FORM_DELIVERY, JSON_DELIVERY, null]);
assert.deepEqual(ev, { sampled: 2, form_header: 1, form_wrapper: 1 });
});
test('parse statuses are counted but only the three', () => {
const [hits, total] = parseFailures([{ status_code: 400 }, { status_code: 415 },
{ status_code: 500 }, { status_code: 200 }, { status_code: null }]);
assert.equal(hits, 2);
assert.equal(total, 5);
});
test('a form hook against a JSON receiver is the finding', () => {
const [state, detail] = verdict('form', 'json');
assert.equal(state, 'form-to-json');
assert.match(detail, /payload= field/);
});
test('a clean delivery log does not soften the finding', () => {
const ev = { sampled: 5, form_header: 5, form_wrapper: 5 };
const [state, detail] = verdict('form', 'json', ev, 0, 40);
assert.equal(state, 'form-to-json');
assert.match(detail, /5 of 5 sampled deliveries/);
});
test('parse statuses are reported as corroboration', () => {
const [state, detail] = verdict('form', 'json', null, 12, 40);
assert.equal(state, 'form-to-json');
assert.match(detail, /12 of 40 recent attempts/);
});
test('the mirror case is named rather than folded in', () => {
assert.equal(verdict('json', 'form')[0], 'json-to-form');
assert.match(repair('json-to-form'), /wrong direction/);
});
test('an undeclared receiver gives a risk and not a verdict', () => {
const [state, detail] = verdict('form', null);
assert.equal(state, 'receiver-undeclared');
assert.match(detail, /risk rather than a finding/);
});
test('consistent pairs are not findings', () => {
assert.equal(verdict('json', 'json')[0], 'consistent-json');
assert.equal(verdict('form', 'form')[0], 'consistent-form');
});
test('a consistent form hook is still warned about the signature', () => {
assert.match(verdict('form', 'form')[1], /raw bytes/);
});
test('an unrecognised encoding is never guessed at', () => {
assert.equal(verdict('unknown', 'json')[0], 'encoding-unknown');
assert.match(repair('encoding-unknown'), /by hand/);
});
test('the receiver flag is normalised defensively', () => {
assert.equal(receiverOf('JSON'), 'json');
assert.equal(receiverOf(' form '), 'form');
assert.equal(receiverOf('maybe'), 'unknown');
assert.equal(receiverOf(null), 'unknown');
});
test('the repair always pairs the encoding with the verifier', () => {
assert.match(repair('form-to-json'), /raw request bytes/);
assert.equal(repair('consistent-json'), 'nothing.');
});
FAQ
Every delivery is a 200. Is this still a problem?
Very possibly, and that is the case worth writing a script for. Plenty of frameworks answer a body they cannot parse with a default value rather than an exception: your handler receives an empty object, matches none of its branches, and returns 200 having done nothing. GitHub records a success because it got one. The only way to tell that state apart from a working integration through the API is to read the request half of a delivery record rather than the response half, which is what the script does.
Can the script tell what my receiver parses?
No, and it does not pretend to. This is the section's standing blind spot: the API describes what GitHub sends and is silent about what happens next. So the sending half is measured properly — the configured encoding, the real content-type header on a sample of deliveries, and the shape of the recorded body — and the receiving half is a flag you set from your own code. The output labels the declared half as declared. Run it without the flag and it reports the encoding and the risk rather than claiming a mismatch it cannot see.
Why did switching content_type to json break our signature check?
Because the signature is an HMAC over the exact bytes of the request body, and you changed the bytes. On a form hook the body is <code>payload=</code> followed by the percent-encoded document, and that whole string is what the signature covers. If your verifier url-decoded first, or parsed the body and hashed a re-serialisation of the object, it was never really verifying the bytes GitHub signed, and it stops agreeing the moment the encoding moves. Read the raw body once, verify against that, and parse the same bytes afterwards.
How is this different from the delivery failure audit?
That note groups delivery attempts by their status code and needs a non-OK status to have something to report. This one lives in the request half of the record, and its most important case has a clean status. They also produce different repairs: a failure audit sends you to your receiver's logs, and this sends you to one field in the hook config. If your deliveries really are failing with 400s, both notes point at the same place and you should read that one too.
Should we just make the receiver handle both encodings?
You can, and it is a reasonable belt-and-braces measure for a receiver that several systems post to. It is not the repair, though. Handling both means two parse paths and, more awkwardly, deciding which bytes the signature covers in each of them, which is exactly the kind of branch that gets one case tested and the other one wrong. One field on one hook is a smaller change than a second code path you have to keep correct.
Related field notes
- Deliveries failing where nobody reads the log
- A receiver still checking the SHA-1 signature
- A webhook with no secret sends no signature
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Repository webhooks — GitHub REST API
- Validating webhook deliveries — GitHub Docs
- Creating webhooks — GitHub Docs
- Troubleshooting webhooks — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.