Skip to content

Commit b3128a0

Browse files
committed
feat: a webhook delivery can be proved to have come from Jira
jira.js/webhooks described what arrives and left the one question that matters unanswered: whether it arrived from Jira at all. x-hub-signature is HMAC-SHA256 over the body's exact bytes, and verifying it was the reader's problem. The subpath stops being types-only, but stays browser-safe and free of Node built-ins: crypto.subtle is a global in both, so nothing is imported and the browser bundle does not move. The cost is a promise, which a webhook handler already is. Every untrustworthy delivery answers false alike — no header, another algorithm, a malformed digest, a wrong one — because the caller's response to all four is the same and telling them apart tells them apart to whoever is probing the endpoint. An empty secret throws instead, being a mistake rather than a failed check. The comparison is constant-time, and the algorithm is pinned to RFC 4231's second vector rather than only to a signature of our own making.
1 parent 4609ee8 commit b3128a0

8 files changed

Lines changed: 345 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,25 @@ Three long-standing requests, all of them the same shape: the client had no seam
112112
});
113113
```
114114

115-
Types only — the subpath compiles to `export {}` and adds nothing to a bundle. There is no parser and no signature check: a webhook body is shaped by the site that sent it, custom fields and installed apps included, so a schema strict enough to be worth having would reject bodies that are perfectly valid elsewhere.
115+
There is no parser: a webhook body is shaped by the site that sent it, custom fields and installed apps included, so a schema strict enough to be worth having would reject bodies that are perfectly valid elsewhere.
116+
117+
**`verifyWebhookSignature` is the one thing here that runs**, because it is the one claim that can be checked rather than asserted. `x-hub-signature` is what distinguishes a delivery from Jira from a POST anyone who found your URL can make, and it is HMAC-SHA256 over the exact bytes of the body.
118+
119+
```ts
120+
import { verifyWebhookSignature } from 'jira.js/webhooks';
121+
122+
app.post('/jira', express.raw({ type: 'application/json' }), async (request, response) => {
123+
const trusted = await verifyWebhookSignature({
124+
body: request.body,
125+
secret: process.env.JIRA_WEBHOOK_SECRET!,
126+
signature: request.get('x-hub-signature'),
127+
});
128+
129+
if (!trusted) return response.sendStatus(401);
130+
});
131+
```
132+
133+
The body must be the bytes that arrived — `JSON.stringify` of a parsed object is a different byte sequence for the same data, and never matches. Every untrustworthy delivery answers `false` alike, whether the header is missing, names another algorithm, or carries a digest of the right shape and the wrong value; only an empty secret throws, being a mistake of yours rather than a failed check. The comparison is constant-time, and `crypto.subtle` is a global in Node and browsers alike, so nothing is imported and the browser bundle is unchanged.
116134

117135
How much is documented is worth stating plainly, because the types say it too. Atlassian publishes one complete payload, the one for issue events; that group is written from it and from a capture of a real delivery, and is the only one whose entity is required. Every other group names its entity optionally, after the entity the event concerns. The headers are lower-cased, as they arrive, and every value is typed as the string an HTTP header is — the retry count included.
118136

docs/guide/webhooks.md

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,23 +91,56 @@ built on it. Every value is a string, including the retry count: an HTTP header
9191
| `x-atlassian-webhook-flow` | `Primary` for the event itself, thirty seconds; `Secondary` for the fallout of a bulk or cascading change, fifteen minutes |
9292
| `x-atlassian-webhook-retry` | how many retries so far; absent on the first attempt |
9393
| `x-atlassian-webhook-trace` | whatever a Connect app attached to the request that caused the event |
94-
| `x-hub-signature` | `sha256=…`, present only on a webhook registered with a secret |
94+
| `x-hub-signature` | `sha256=…`, present only on a webhook registered with a secret — pass it to `verifyWebhookSignature` |
9595

9696
Deleting an issue is the clearest illustration of the flow header: `jira:issue_deleted` goes out as `Primary`, and
9797
every dependent `comment_deleted`, `attachment_deleted` and `issuelink_deleted` follows as `Secondary`, possibly
9898
minutes later.
9999

100-
## What this does not do
100+
## There is no parser
101101

102-
**It does not parse.** The casts above are the interface, deliberately. A webhook body is shaped by the site that sent
102+
The casts above are the interface, deliberately. A webhook body is shaped by the site that sent
103103
it — custom fields under generated keys in `issue.fields`, whatever an installed app adds, a Data Center release that
104104
differs from Cloud — and a schema strict enough to be worth having would reject bodies that are perfectly valid
105105
somewhere else. Elsewhere in this library a response is validated because the API documents it; here there is nothing
106106
to validate against.
107107

108-
**It does not verify signatures.** `x-hub-signature` is the only thing that proves a request came from Jira rather
109-
than from whoever found your URL, and checking it is yours to do. This library does not, because a signature check
110-
that quietly compares the wrong way is worse than none, and there is nothing here to test one against.
108+
## Verifying the signature
109+
110+
`x-hub-signature` is the only thing that proves a request came from Jira rather than from whoever found your URL. A
111+
webhook registered with a secret carries it as `sha256=<hex>`, over HMAC-SHA256 of the exact bytes of the body.
112+
113+
```ts
114+
import express from 'express';
115+
import { verifyWebhookSignature, type WebhookPayload } from 'jira.js/webhooks';
116+
117+
app.post('/jira', express.raw({ type: 'application/json' }), async (request, response) => {
118+
const trusted = await verifyWebhookSignature({
119+
body: request.body,
120+
secret: process.env.JIRA_WEBHOOK_SECRET!,
121+
signature: request.get('x-hub-signature'),
122+
});
123+
124+
if (!trusted) return response.sendStatus(401);
125+
126+
const payload = JSON.parse(request.body.toString()) as WebhookPayload;
127+
128+
response.sendStatus(200);
129+
});
130+
```
131+
132+
**The body must be the bytes that arrived.** This is where the check usually goes wrong: `express.json()` and every
133+
equivalent hand you a parsed object, and `JSON.stringify` of that object is a different byte sequence for the same
134+
data — key order, whitespace and number formatting are not preserved — so the signature will never match. Reach for
135+
whatever your framework calls a raw body.
136+
137+
The answer is `false` for every way a delivery can fail to be trustworthy: no header, an algorithm other than
138+
`sha256`, a digest that is not hexadecimal, a digest of the right shape and the wrong value. Your response to all four
139+
is the same, and distinguishing them would distinguish them for whoever is probing the endpoint too. The one thing it
140+
throws on is an empty secret, which is a mistake of yours rather than a failed check.
141+
142+
The comparison is constant-time, and nothing is imported to do any of it: `crypto.subtle` is a global in Node and in
143+
browsers alike, so this subpath still adds nothing to a browser bundle.
111144

112145
## Registering one
113146

docs/ru/guide/webhooks.md

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,22 +91,55 @@ case 'jira:issue_updated':
9191
| `x-atlassian-webhook-flow` | `Primary` — само событие, тридцать секунд; `Secondary` — последствия массовой или каскадной операции, пятнадцать минут |
9292
| `x-atlassian-webhook-retry` | сколько было повторов; при первой попытке отсутствует |
9393
| `x-atlassian-webhook-trace` | то, что Connect-приложение приложило к запросу, вызвавшему событие |
94-
| `x-hub-signature` | `sha256=…`, только у вебхука, зарегистрированного с секретом |
94+
| `x-hub-signature` | `sha256=…`, только у вебхука, зарегистрированного с секретом — передайте в `verifyWebhookSignature` |
9595

9696
Удаление задачи лучше всего показывает смысл заголовка потока: `jira:issue_deleted` уходит как `Primary`, а все
9797
зависимые `comment_deleted`, `attachment_deleted` и `issuelink_deleted` — следом как `Secondary`, возможно через
9898
несколько минут.
9999

100-
## Чего здесь нет
100+
## Разбора здесь нет
101101

102-
**Разбора.** Приведения типов выше — и есть интерфейс, намеренно. Тело вебхука определяется сайтом, который его послал:
102+
Приведения типов выше — и есть интерфейс, намеренно. Тело вебхука определяется сайтом, который его послал:
103103
пользовательские поля под сгенерированными ключами в `issue.fields`, что угодно от установленного приложения, релиз
104104
Data Center, отличающийся от Cloud. Схема, достаточно строгая, чтобы её стоило иметь, отвергала бы тела, совершенно
105105
правильные где-то ещё. В остальной библиотеке ответ валидируется, потому что API его описывает; здесь описывать нечем.
106106

107-
**Проверки подписи.** `x-hub-signature` — единственное, что доказывает, что запрос пришёл от Jira, а не от того, кто
108-
нашёл ваш URL, и проверять его вам. Библиотека этого не делает: проверка подписи, которая тихо сравнивает не то, хуже
109-
её отсутствия, а протестировать её здесь не на чем.
107+
## Проверка подписи
108+
109+
`x-hub-signature` — единственное, что доказывает, что запрос пришёл от Jira, а не от того, кто нашёл ваш URL. Вебхук,
110+
зарегистрированный с секретом, несёт её как `sha256=<hex>` — HMAC-SHA256 по точным байтам тела.
111+
112+
```ts
113+
import express from 'express';
114+
import { verifyWebhookSignature, type WebhookPayload } from 'jira.js/webhooks';
115+
116+
app.post('/jira', express.raw({ type: 'application/json' }), async (request, response) => {
117+
const trusted = await verifyWebhookSignature({
118+
body: request.body,
119+
secret: process.env.JIRA_WEBHOOK_SECRET!,
120+
signature: request.get('x-hub-signature'),
121+
});
122+
123+
if (!trusted) return response.sendStatus(401);
124+
125+
const payload = JSON.parse(request.body.toString()) as WebhookPayload;
126+
127+
response.sendStatus(200);
128+
});
129+
```
130+
131+
**Телом должны быть пришедшие байты.** Именно здесь проверка обычно и ломается: `express.json()` и всё ему подобное
132+
отдают разобранный объект, а `JSON.stringify` от него — уже другая последовательность байтов для тех же данных:
133+
порядок ключей, пробелы и запись чисел не сохраняются, и подпись не совпадёт никогда. Возьмите то, что ваш фреймворк
134+
называет сырым телом.
135+
136+
Ответ — `false` на любой способ доставке оказаться недостоверной: заголовка нет, алгоритм не `sha256`, дайджест не
137+
шестнадцатеричный, дайджест нужной формы и неверного значения. Ваша реакция на все четыре одинакова, а различать их
138+
означало бы различать их и для того, кто прощупывает эндпоинт. Исключение бросается только на пустой секрет — это
139+
ошибка ваша, а не провалившаяся проверка.
140+
141+
Сравнение идёт за постоянное время, и ради всего этого ничего не импортируется: `crypto.subtle` — глобальный объект и
142+
в Node, и в браузере, так что сабпать по-прежнему ничего не добавляет к браузерной сборке.
110143

111144
## Как зарегистрировать
112145

scripts/checkConsumers.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,7 @@ try {
6565
.filter(entry => entry !== './browser' && entry !== './package.json')
6666
.map(entry => (entry === '.' ? 'jira.js' : `jira.js/${entry.slice(2)}`));
6767

68-
// `jira.js/webhooks` describes what Jira posts to a server of yours; there is nothing to call, so it compiles to
69-
// `export {}`. An empty namespace is the right answer there, and a populated one would mean runtime code crept in.
70-
const TYPES_ONLY = ['jira.js/webhooks'];
68+
const TYPES_ONLY: string[] = [];
7169

7270
const runtimeProbe = [
7371
...SUBPATHS.map((subpath, index) => `import * as m${index} from '${subpath}';`),

src/webhooks/headers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ export interface WebhookHeaders {
3838
* The body's signature, as `method=signature` — `sha256=…` in practice. Present only on a webhook registered with
3939
* a secret, and the only thing that tells you the request really came from Jira.
4040
*
41-
* Verifying it is yours to do. This library does not, because a signature check that quietly matches the wrong way
42-
* is worse than none, and it cannot be tested against anything here.
41+
* Pass it to `verifyWebhookSignature` along with the raw body and the secret you registered. A delivery that fails
42+
* that check is one anyone could have sent.
4343
*/
4444
'x-hub-signature'?: string;
4545
}

src/webhooks/index.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,17 @@
2222
* });
2323
* ```
2424
*
25-
* Types only, and deliberately: this subpath compiles away to nothing. There is no parser and no signature check,
26-
* because a webhook body is shaped by the site that sent it — custom fields, apps, a Data Center release Atlassian
27-
* documents separately — and a schema strict enough to be worth having would throw on bodies that are perfectly
28-
* valid. The cast above is the honest interface: you are telling the compiler what Jira sends, and this subpath is
29-
* where that claim is written down.
25+
* There is no parser, and deliberately so: a webhook body is shaped by the site that sent it — custom fields, apps, a
26+
* Data Center release Atlassian documents separately — and a schema strict enough to be worth having would throw on
27+
* bodies that are perfectly valid. The cast above is the honest interface: you are telling the compiler what Jira
28+
* sends, and this subpath is where that claim is written down.
29+
*
30+
* The one thing here that runs is `verifyWebhookSignature`, because it is the one claim that can be checked rather
31+
* than asserted. HMAC-SHA256 over the raw body either matches the secret you registered or it does not, and until it
32+
* does you know nothing about where the request came from.
3033
*/
34+
export { verifyWebhookSignature, type VerifyWebhookSignatureOptions } from './verify';
35+
3136
export type {
3237
WebhookEvent,
3338
IssueWebhookEvent,

src/webhooks/verify.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* Checks that a webhook body really came from your Jira site.
3+
*
4+
* A webhook registered with a secret arrives signed: Jira computes HMAC-SHA256 over the exact bytes of the request
5+
* body and sends the digest as `X-Hub-Signature: sha256=<hex>`. Recomputing it with the same secret is the only thing
6+
* that distinguishes a delivery from Jira from a POST anyone on the internet can make to the same URL.
7+
*
8+
* ```ts
9+
* import { verifyWebhookSignature } from 'jira.js/webhooks';
10+
*
11+
* app.post('/jira', express.raw({ type: 'application/json' }), async (request, response) => {
12+
* const trusted = await verifyWebhookSignature({
13+
* body: request.body,
14+
* secret: process.env.JIRA_WEBHOOK_SECRET!,
15+
* signature: request.get('x-hub-signature'),
16+
* });
17+
*
18+
* if (!trusted) return response.sendStatus(401);
19+
*
20+
* const payload = JSON.parse(request.body.toString()) as WebhookPayload;
21+
* });
22+
* ```
23+
*
24+
* The body must be the bytes that arrived. Re-serialising a parsed object — `JSON.stringify(request.body)` — produces
25+
* a different byte sequence for the same data, because key order, whitespace and number formatting are not preserved,
26+
* and the signature will not match. Every framework has a way to keep the raw body; use it.
27+
*
28+
* Nothing here is imported: `crypto.subtle` is a global in Node 18 and later and in every browser, so this subpath
29+
* stays free of Node built-ins and the browser bundle is unaffected.
30+
*/
31+
32+
/** How a signature was computed. Jira sends `sha256`; nothing else is accepted. */
33+
const ALGORITHM = 'sha256';
34+
35+
export interface VerifyWebhookSignatureOptions {
36+
/**
37+
* The raw request body, exactly as it arrived — not a parsed object re-serialised, whose bytes differ from what was
38+
* signed.
39+
*/
40+
body: string | ArrayBuffer | Uint8Array;
41+
42+
/** The secret you gave Jira when you registered the webhook. */
43+
secret: string;
44+
45+
/**
46+
* The `X-Hub-Signature` header. Passing `undefined` is the ordinary case of an unsigned delivery and answers
47+
* `false`, so a missing header needs no separate branch of yours.
48+
*/
49+
signature: string | undefined;
50+
}
51+
52+
/**
53+
* Copies into an array `crypto.subtle` accepts.
54+
*
55+
* `BufferSource` excludes a view onto a `SharedArrayBuffer`, which a plain `Uint8Array` may be; constructing a new one
56+
* both narrows the type and respects the offset and length of a view onto a larger buffer, which a webhook body read
57+
* out of a pooled buffer usually is.
58+
*/
59+
function toBytes(value: string | ArrayBuffer | Uint8Array): Uint8Array<ArrayBuffer> {
60+
if (typeof value === 'string') return new TextEncoder().encode(value);
61+
62+
return new Uint8Array(value);
63+
}
64+
65+
function fromHex(hex: string): Uint8Array | undefined {
66+
if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return undefined;
67+
68+
const bytes = new Uint8Array(hex.length / 2);
69+
70+
for (let index = 0; index < bytes.length; index++) {
71+
bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
72+
}
73+
74+
return bytes;
75+
}
76+
77+
/**
78+
* Compares two digests without letting the time taken reveal how far they matched.
79+
*
80+
* A `===` on hex strings stops at the first differing character, and an attacker who can send many deliveries and
81+
* measure the replies can recover a valid signature one character at a time. Differing lengths are answered at once
82+
* on purpose: the length of a digest is public, its contents are not.
83+
*/
84+
function equalInConstantTime(left: Uint8Array, right: Uint8Array): boolean {
85+
if (left.length !== right.length) return false;
86+
87+
let difference = 0;
88+
89+
for (let index = 0; index < left.length; index++) difference |= left[index]! ^ right[index]!;
90+
91+
return difference === 0;
92+
}
93+
94+
/**
95+
* Whether the body carries a signature this secret produces.
96+
*
97+
* Answers `false` for every way a delivery can fail to be trustworthy — no header, an algorithm other than `sha256`,
98+
* a digest that is not hexadecimal, a digest of the right shape and the wrong value — because a handler's response to
99+
* all four is the same, and telling them apart to the caller would tell them apart to whoever is probing the endpoint.
100+
*
101+
* Throws only on a mistake of yours: an empty secret would make every delivery verify against a value an attacker can
102+
* compute, so it is a programming error rather than a failed check.
103+
*/
104+
export async function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): Promise<boolean> {
105+
const { body, secret, signature } = options;
106+
107+
if (secret.length === 0) {
108+
throw new TypeError('verifyWebhookSignature: the secret is empty, which would verify every body ever sent');
109+
}
110+
111+
if (signature === undefined) return false;
112+
113+
const separator = signature.indexOf('=');
114+
115+
if (signature.slice(0, separator) !== ALGORITHM) return false;
116+
117+
const sent = fromHex(signature.slice(separator + 1));
118+
119+
if (sent === undefined) return false;
120+
121+
const key = await crypto.subtle.importKey(
122+
'raw',
123+
toBytes(secret),
124+
{ name: 'HMAC', hash: 'SHA-256' },
125+
false,
126+
['sign'],
127+
);
128+
129+
const computed = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBytes(body)));
130+
131+
return equalInConstantTime(computed, sent);
132+
}

0 commit comments

Comments
 (0)