@allus-fyi/company-data 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +706 -0
- package/dist/cjs/buffer.js +352 -0
- package/dist/cjs/client.js +396 -0
- package/dist/cjs/config.js +241 -0
- package/dist/cjs/crypto.js +288 -0
- package/dist/cjs/errors.js +96 -0
- package/dist/cjs/http.js +272 -0
- package/dist/cjs/index.js +74 -0
- package/dist/cjs/models.js +300 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/pump.js +279 -0
- package/dist/cjs/webhooks.js +335 -0
- package/dist/cjs/xml.js +257 -0
- package/dist/esm/buffer.js +348 -0
- package/dist/esm/client.js +392 -0
- package/dist/esm/config.js +237 -0
- package/dist/esm/crypto.js +281 -0
- package/dist/esm/errors.js +86 -0
- package/dist/esm/http.js +267 -0
- package/dist/esm/index.js +37 -0
- package/dist/esm/models.js +292 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/pump.js +275 -0
- package/dist/esm/webhooks.js +329 -0
- package/dist/esm/xml.js +252 -0
- package/dist/types/buffer.d.ts +109 -0
- package/dist/types/client.d.ts +150 -0
- package/dist/types/config.d.ts +86 -0
- package/dist/types/crypto.d.ts +125 -0
- package/dist/types/errors.d.ts +73 -0
- package/dist/types/http.d.ts +80 -0
- package/dist/types/index.d.ts +36 -0
- package/dist/types/models.d.ts +154 -0
- package/dist/types/pump.d.ts +118 -0
- package/dist/types/webhooks.d.ts +99 -0
- package/dist/types/xml.d.ts +42 -0
- package/docs/config.md +93 -0
- package/docs/errors.md +87 -0
- package/docs/model.md +141 -0
- package/docs/pump.md +130 -0
- package/docs/webhooks.md +140 -0
- package/package.json +54 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Webhook receiver helpers.
|
|
4
|
+
*
|
|
5
|
+
* The lower-latency push alternative to polling the changes feed. The platform
|
|
6
|
+
* delivers each change event to the company's configured webhook URL with:
|
|
7
|
+
*
|
|
8
|
+
* - `X-Allus-Webhook-Id` — which webhook this is (selects the HMAC secret).
|
|
9
|
+
* - `X-Allus-Signature` — `HMAC-SHA256(rawBody, secret)` as lowercase hex.
|
|
10
|
+
* - the body — the same slug-keyed {@link Change} shape as the pull feed,
|
|
11
|
+
* JSON or XML. If the webhook has `encrypt_payload` on, the body is REPLACED
|
|
12
|
+
* by a `{"_enc":1,...}` envelope encrypted to the company **account** key (and
|
|
13
|
+
* the HMAC is then over that envelope — it is the final body that was sent).
|
|
14
|
+
*
|
|
15
|
+
* Webhook delivery auth is per-webhook and may be any of five methods (hmac,
|
|
16
|
+
* bearer, basic, custom header, or none); {@link verifyWebhook} dispatches on the
|
|
17
|
+
* single method configured in {@link Config} ({@link Config.webhookAuthMethod}).
|
|
18
|
+
*
|
|
19
|
+
* All secrets/keys come from {@link Config}. **These helpers take NO key or secret
|
|
20
|
+
* arguments** — only the raw body, the headers, the config, and (for value typing)
|
|
21
|
+
* the same decrypt/type closures the {@link Client} already holds.
|
|
22
|
+
*
|
|
23
|
+
* The account-key envelope is webhook-specific: the platform wraps it with
|
|
24
|
+
* OpenSSL's DEFAULT OAEP padding (MGF1-**SHA1**), NOT the SHA-256 wrapper used for
|
|
25
|
+
* person field values. So unwrapping the envelope uses an OAEP-SHA1 path here
|
|
26
|
+
* (Node's default `oaepHash`, pinned explicitly to `'sha1'` for clarity), while the
|
|
27
|
+
* inner field `value` (still a service-key wrapper) decrypts with the normal
|
|
28
|
+
* SHA-256 {@link decrypt}. HMAC is always computed over the raw bytes, never the
|
|
29
|
+
* parsed tree.
|
|
30
|
+
*/
|
|
31
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
exports.verifyWebhook = verifyWebhook;
|
|
33
|
+
exports.parseWebhook = parseWebhook;
|
|
34
|
+
exports.handleWebhook = handleWebhook;
|
|
35
|
+
exports.loadAccountKey = loadAccountKey;
|
|
36
|
+
const node_crypto_1 = require("node:crypto");
|
|
37
|
+
const node_fs_1 = require("node:fs");
|
|
38
|
+
const crypto_js_1 = require("./crypto.js");
|
|
39
|
+
const errors_js_1 = require("./errors.js");
|
|
40
|
+
const models_js_1 = require("./models.js");
|
|
41
|
+
const xml_js_1 = require("./xml.js");
|
|
42
|
+
const HDR_WEBHOOK_ID = 'x-allus-webhook-id';
|
|
43
|
+
const HDR_SIGNATURE = 'x-allus-signature';
|
|
44
|
+
const ENC_MARKER = '_enc';
|
|
45
|
+
// ── header helpers ─────────────────────────────────────────────────────────────
|
|
46
|
+
/** Case-insensitive header lookup (frameworks normalize casing inconsistently). */
|
|
47
|
+
function header(headers, name) {
|
|
48
|
+
if (!headers)
|
|
49
|
+
return null;
|
|
50
|
+
const target = name.toLowerCase();
|
|
51
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
52
|
+
if (key.toLowerCase() === target) {
|
|
53
|
+
if (Array.isArray(value))
|
|
54
|
+
return value.length > 0 ? String(value[0]) : null;
|
|
55
|
+
return value != null ? String(value) : null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
function asBytes(rawBody) {
|
|
61
|
+
if (Buffer.isBuffer(rawBody))
|
|
62
|
+
return rawBody;
|
|
63
|
+
if (rawBody instanceof Uint8Array)
|
|
64
|
+
return Buffer.from(rawBody);
|
|
65
|
+
if (typeof rawBody === 'string')
|
|
66
|
+
return Buffer.from(rawBody, 'utf8');
|
|
67
|
+
throw new errors_js_1.WebhookError('webhook rawBody must be a Buffer, Uint8Array, or string');
|
|
68
|
+
}
|
|
69
|
+
// ── verify ─────────────────────────────────────────────────────────────────────
|
|
70
|
+
/**
|
|
71
|
+
* Verify a webhook against the SINGLE configured auth method.
|
|
72
|
+
*
|
|
73
|
+
* Mirrors the platform's per-webhook delivery auth (one method per webhook):
|
|
74
|
+
*
|
|
75
|
+
* - `hmac` — recompute `HMAC-SHA256(rawBody, secret)` (secret selected by
|
|
76
|
+
* `X-Allus-Webhook-Id`) and constant-time-compare to `X-Allus-Signature`.
|
|
77
|
+
* - `bearer` — `Authorization` equals `Bearer <token>`.
|
|
78
|
+
* - `basic` — `Authorization` equals `Basic <base64(user:pass)>`.
|
|
79
|
+
* - `header` — the configured custom header equals the configured value.
|
|
80
|
+
* - `none` — always `true` (explicit opt-out).
|
|
81
|
+
*
|
|
82
|
+
* All comparisons are constant-time. Returns `false` on a missing/mismatched
|
|
83
|
+
* credential, or when no method is configured — never throws for a bad credential
|
|
84
|
+
* (that is {@link handleWebhook}'s job). Which method is used is decided entirely
|
|
85
|
+
* by config ({@link Config.webhookAuthMethod}); config loading guarantees at most
|
|
86
|
+
* one is set. The HMAC is over the exact raw bytes.
|
|
87
|
+
*/
|
|
88
|
+
function verifyWebhook(rawBody, headers, config) {
|
|
89
|
+
const method = config.webhookAuthMethod();
|
|
90
|
+
if (method === null)
|
|
91
|
+
return false;
|
|
92
|
+
if (method === 'none')
|
|
93
|
+
return true;
|
|
94
|
+
if (method === 'bearer') {
|
|
95
|
+
const got = header(headers, 'authorization');
|
|
96
|
+
if (got === null)
|
|
97
|
+
return false;
|
|
98
|
+
return constantTimeStringEqual(got, 'Bearer ' + (config.webhookBearerToken ?? ''));
|
|
99
|
+
}
|
|
100
|
+
if (method === 'basic') {
|
|
101
|
+
const got = header(headers, 'authorization');
|
|
102
|
+
if (got === null)
|
|
103
|
+
return false;
|
|
104
|
+
const basic = config.webhookBasic;
|
|
105
|
+
const creds = `${basic.username}:${basic.password}`;
|
|
106
|
+
const token = Buffer.from(creds, 'utf8').toString('base64');
|
|
107
|
+
return constantTimeStringEqual(got, 'Basic ' + token);
|
|
108
|
+
}
|
|
109
|
+
if (method === 'header') {
|
|
110
|
+
const hdr = config.webhookHeader;
|
|
111
|
+
const got = header(headers, hdr.name);
|
|
112
|
+
if (got === null)
|
|
113
|
+
return false;
|
|
114
|
+
return constantTimeStringEqual(got, hdr.value);
|
|
115
|
+
}
|
|
116
|
+
// method === 'hmac'
|
|
117
|
+
const body = asBytes(rawBody);
|
|
118
|
+
const signature = header(headers, HDR_SIGNATURE);
|
|
119
|
+
if (!signature)
|
|
120
|
+
return false;
|
|
121
|
+
const webhookId = header(headers, HDR_WEBHOOK_ID);
|
|
122
|
+
const secret = config.webhookSecret(webhookId);
|
|
123
|
+
if (!secret)
|
|
124
|
+
return false;
|
|
125
|
+
const expected = (0, node_crypto_1.createHmac)('sha256', secret).update(body).digest('hex');
|
|
126
|
+
return constantTimeStringEqual(expected, signature.trim().toLowerCase());
|
|
127
|
+
}
|
|
128
|
+
/** Constant-time compare two UTF-8 strings (length-safe). */
|
|
129
|
+
function constantTimeStringEqual(a, b) {
|
|
130
|
+
// Compare fixed-length byte buffers; if lengths differ, compare against `a`
|
|
131
|
+
// itself so we never short-circuit on a length mismatch (timing-safe).
|
|
132
|
+
const ab = Buffer.from(a, 'utf8');
|
|
133
|
+
const bb = Buffer.from(b, 'utf8');
|
|
134
|
+
if (ab.length !== bb.length) {
|
|
135
|
+
// Still do a constant-time compare against a same-length buffer to avoid a
|
|
136
|
+
// length-based timing oracle, then return false.
|
|
137
|
+
(0, node_crypto_1.timingSafeEqual)(ab, ab);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return (0, node_crypto_1.timingSafeEqual)(ab, bb);
|
|
141
|
+
}
|
|
142
|
+
// ── parse ──────────────────────────────────────────────────────────────────────
|
|
143
|
+
/**
|
|
144
|
+
* Parse a webhook body → a typed {@link Change}.
|
|
145
|
+
*
|
|
146
|
+
* Does NOT verify the signature (use {@link handleWebhook} for verify+parse).
|
|
147
|
+
* Handles JSON and XML bodies, and an `encrypt_payload` account-key envelope: if
|
|
148
|
+
* the (JSON) body is a `{"_enc":1,...}` wrapper, it is first unwrapped with the
|
|
149
|
+
* account private key (OAEP-SHA1) into the inner serialized payload, which is then
|
|
150
|
+
* parsed. The inner field `value` (a service-key wrapper) is decrypted by the same
|
|
151
|
+
* model factory the feed uses, so a webhook `Change` is byte-identical to a feed
|
|
152
|
+
* `Change`.
|
|
153
|
+
*
|
|
154
|
+
* `deps.accountKey` is an optional pre-loaded account private key (the
|
|
155
|
+
* {@link Client} loads it ONCE and reuses it, so an `encrypt_payload` webhook
|
|
156
|
+
* doesn't re-read the PEM + re-run PBKDF2 ~100k iters per request). When undefined,
|
|
157
|
+
* the key is loaded from config on demand — config-only key handling either way.
|
|
158
|
+
*/
|
|
159
|
+
function parseWebhook(rawBody, headers, config, deps) {
|
|
160
|
+
// `headers` is part of the webhook contract (verify reads them; parse keeps the
|
|
161
|
+
// symmetric signature) but the body/envelope decode is header-independent — the
|
|
162
|
+
// encrypt_payload envelope is self-describing (`{"_enc":1,…}`).
|
|
163
|
+
void headers;
|
|
164
|
+
const body = asBytes(rawBody);
|
|
165
|
+
const payload = decodePayload(body, config, deps.accountKey);
|
|
166
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
167
|
+
throw new errors_js_1.WebhookError('webhook payload is not a JSON/XML object');
|
|
168
|
+
}
|
|
169
|
+
return models_js_1.Change.fromApi(payload, {
|
|
170
|
+
typeForSlug: deps.typeForSlug,
|
|
171
|
+
decryptValue: deps.decryptValue,
|
|
172
|
+
binaryFetch: deps.binaryFetch,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Verify + parse a webhook in one call.
|
|
177
|
+
*
|
|
178
|
+
* Throws {@link WebhookError} on a bad/unknown signature; otherwise returns the
|
|
179
|
+
* typed {@link Change}. The typical one-liner inside a webhook route. `deps.accountKey`
|
|
180
|
+
* (optional) is a pre-loaded account private key reused for the `encrypt_payload`
|
|
181
|
+
* envelope (see {@link parseWebhook}).
|
|
182
|
+
*/
|
|
183
|
+
function handleWebhook(rawBody, headers, config, deps) {
|
|
184
|
+
if (!verifyWebhook(rawBody, headers, config)) {
|
|
185
|
+
throw new errors_js_1.WebhookError('webhook signature verification failed');
|
|
186
|
+
}
|
|
187
|
+
return parseWebhook(rawBody, headers, config, deps);
|
|
188
|
+
}
|
|
189
|
+
// ── payload decoding (JSON / XML / encrypt_payload envelope) ────────────────────
|
|
190
|
+
function decodePayload(body, config, accountKey) {
|
|
191
|
+
const text = body.toString('utf8').trim();
|
|
192
|
+
// An encrypt_payload envelope is always JSON ({"_enc":1,...}). Detect + unwrap it
|
|
193
|
+
// before anything else (the inner payload is then JSON or XML per format).
|
|
194
|
+
if (text.startsWith('{')) {
|
|
195
|
+
let obj;
|
|
196
|
+
try {
|
|
197
|
+
obj = JSON.parse(text);
|
|
198
|
+
}
|
|
199
|
+
catch (exc) {
|
|
200
|
+
throw new errors_js_1.WebhookError(`webhook body is not valid JSON: ${exc.message}`);
|
|
201
|
+
}
|
|
202
|
+
if (obj !== null &&
|
|
203
|
+
typeof obj === 'object' &&
|
|
204
|
+
!Array.isArray(obj) &&
|
|
205
|
+
obj[ENC_MARKER] === 1 &&
|
|
206
|
+
['k', 'iv', 'd'].every((f) => f in obj)) {
|
|
207
|
+
const inner = unwrapAccountEnvelope(obj, config, accountKey);
|
|
208
|
+
return decodeInner(inner);
|
|
209
|
+
}
|
|
210
|
+
return obj;
|
|
211
|
+
}
|
|
212
|
+
// Otherwise an XML body (the platform's <response> serialization).
|
|
213
|
+
if (text.startsWith('<')) {
|
|
214
|
+
try {
|
|
215
|
+
return (0, xml_js_1.parseXml)(text);
|
|
216
|
+
}
|
|
217
|
+
catch (exc) {
|
|
218
|
+
throw new errors_js_1.WebhookError(`webhook body is not valid XML: ${exc.message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
throw new errors_js_1.WebhookError('webhook body is neither JSON nor XML');
|
|
222
|
+
}
|
|
223
|
+
function decodeInner(innerText) {
|
|
224
|
+
const stripped = innerText.trim();
|
|
225
|
+
if (stripped.startsWith('<')) {
|
|
226
|
+
try {
|
|
227
|
+
return (0, xml_js_1.parseXml)(stripped);
|
|
228
|
+
}
|
|
229
|
+
catch (exc) {
|
|
230
|
+
throw new errors_js_1.WebhookError(`decrypted webhook payload is not valid XML: ${exc.message}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
return JSON.parse(stripped);
|
|
235
|
+
}
|
|
236
|
+
catch (exc) {
|
|
237
|
+
throw new errors_js_1.WebhookError(`decrypted webhook payload is not valid JSON: ${exc.message}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// ── account-key envelope unwrap (OAEP-SHA1 — webhook-specific) ───────────────────
|
|
241
|
+
/**
|
|
242
|
+
* Load the account private key from config ONCE (or `null` if not configured).
|
|
243
|
+
*
|
|
244
|
+
* Reused by the {@link Client} so an `encrypt_payload` webhook never re-reads the
|
|
245
|
+
* PEM + re-runs PBKDF2 (~100k iters) per request — the account key is loaded a
|
|
246
|
+
* single time at client construction, exactly like the service key. Returns `null`
|
|
247
|
+
* when no `accountPrivateKey` is configured (the SDK only needs it for
|
|
248
|
+
* `encrypt_payload` webhooks). Throws {@link WebhookError} on a read / passphrase /
|
|
249
|
+
* PEM problem.
|
|
250
|
+
*/
|
|
251
|
+
function loadAccountKey(config) {
|
|
252
|
+
if (!config.accountPrivateKey)
|
|
253
|
+
return null;
|
|
254
|
+
let pem;
|
|
255
|
+
try {
|
|
256
|
+
pem = (0, node_fs_1.readFileSync)(config.accountPrivateKey);
|
|
257
|
+
}
|
|
258
|
+
catch (exc) {
|
|
259
|
+
throw new errors_js_1.WebhookError(`could not read accountPrivateKey PEM: ${config.accountPrivateKey}: ${exc.message}`);
|
|
260
|
+
}
|
|
261
|
+
const passphrase = config.accountPassphrase ?? '';
|
|
262
|
+
try {
|
|
263
|
+
return (0, node_crypto_1.createPrivateKey)({ key: pem, passphrase });
|
|
264
|
+
}
|
|
265
|
+
catch (exc) {
|
|
266
|
+
throw new errors_js_1.WebhookError(`could not load account private key: ${exc.message}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function unwrapAccountEnvelope(envelope, config, accountKey) {
|
|
270
|
+
const key = accountKey ?? loadAccountKey(config);
|
|
271
|
+
if (key === null || key === undefined) {
|
|
272
|
+
throw new errors_js_1.WebhookError('received an encrypt_payload webhook but no accountPrivateKey is configured');
|
|
273
|
+
}
|
|
274
|
+
return decryptOaepSha1(envelope, key);
|
|
275
|
+
}
|
|
276
|
+
function b64(value, name) {
|
|
277
|
+
if (typeof value !== 'string') {
|
|
278
|
+
throw new errors_js_1.WebhookError(`envelope field '${name}' must be a base64 string`);
|
|
279
|
+
}
|
|
280
|
+
const buf = Buffer.from(value, 'base64');
|
|
281
|
+
const normalized = value.replace(/\s+/g, '');
|
|
282
|
+
if (buf.toString('base64').replace(/=+$/, '') !== normalized.replace(/=+$/, '')) {
|
|
283
|
+
throw new errors_js_1.WebhookError(`envelope field '${name}' is not valid base64`);
|
|
284
|
+
}
|
|
285
|
+
return buf;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* RSA-OAEP(**SHA-1**, MGF1-SHA1) unwrap + AES-256-GCM decrypt → utf-8 string.
|
|
289
|
+
*
|
|
290
|
+
* Mirrors {@link decrypt} but pins SHA-1 for the OAEP/MGF1 hash to match the
|
|
291
|
+
* account-key envelope (the only place the platform uses SHA-1 OAEP). Node defaults
|
|
292
|
+
* `oaepHash` to SHA-1 already; we set it explicitly for clarity + to be robust to a
|
|
293
|
+
* future default change.
|
|
294
|
+
*/
|
|
295
|
+
function decryptOaepSha1(wrapper, privateKey) {
|
|
296
|
+
const encKey = b64(wrapper.k, 'k');
|
|
297
|
+
const iv = b64(wrapper.iv, 'iv');
|
|
298
|
+
const ciphertextWithTag = b64(wrapper.d, 'd');
|
|
299
|
+
if (iv.length !== crypto_js_1.GCM_IV_LEN) {
|
|
300
|
+
throw new errors_js_1.WebhookError(`envelope iv must be ${crypto_js_1.GCM_IV_LEN} bytes, got ${iv.length}`);
|
|
301
|
+
}
|
|
302
|
+
if (ciphertextWithTag.length < crypto_js_1.GCM_TAG_LEN) {
|
|
303
|
+
throw new errors_js_1.WebhookError('envelope ciphertext too short to contain a GCM tag');
|
|
304
|
+
}
|
|
305
|
+
let aesKey;
|
|
306
|
+
try {
|
|
307
|
+
aesKey = (0, node_crypto_1.privateDecrypt)({
|
|
308
|
+
key: privateKey,
|
|
309
|
+
padding: node_crypto_1.constants.RSA_PKCS1_OAEP_PADDING,
|
|
310
|
+
oaepHash: 'sha1',
|
|
311
|
+
}, encKey);
|
|
312
|
+
}
|
|
313
|
+
catch (exc) {
|
|
314
|
+
throw new errors_js_1.WebhookError(`account-key envelope RSA-OAEP unwrap failed (wrong account key?): ${exc.message}`);
|
|
315
|
+
}
|
|
316
|
+
if (aesKey.length !== 32) {
|
|
317
|
+
throw new errors_js_1.WebhookError(`unwrapped envelope AES key must be 32 bytes, got ${aesKey.length}`);
|
|
318
|
+
}
|
|
319
|
+
const tag = ciphertextWithTag.subarray(ciphertextWithTag.length - crypto_js_1.GCM_TAG_LEN);
|
|
320
|
+
const ciphertext = ciphertextWithTag.subarray(0, ciphertextWithTag.length - crypto_js_1.GCM_TAG_LEN);
|
|
321
|
+
let plaintext;
|
|
322
|
+
try {
|
|
323
|
+
const decipher = (0, node_crypto_1.createDecipheriv)('aes-256-gcm', aesKey, iv);
|
|
324
|
+
decipher.setAuthTag(tag);
|
|
325
|
+
plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
throw new errors_js_1.WebhookError('account-key envelope AES-GCM tag mismatch');
|
|
329
|
+
}
|
|
330
|
+
const out = plaintext.toString('utf8');
|
|
331
|
+
if (!Buffer.from(out, 'utf8').equals(plaintext)) {
|
|
332
|
+
throw new errors_js_1.WebhookError('decrypted account-key envelope is not valid UTF-8');
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
package/dist/cjs/xml.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal, XXE-safe XML parser for the platform's wire serialization.
|
|
4
|
+
*
|
|
5
|
+
* The company-data API can serve XML (`Accept: application/xml` / `format: "xml"`).
|
|
6
|
+
* The platform serializer renders:
|
|
7
|
+
*
|
|
8
|
+
* - a `<response>` document root;
|
|
9
|
+
* - a list (int keys) as repeated `<item>` children — so an element whose
|
|
10
|
+
* every child is `<item>` becomes an array;
|
|
11
|
+
* - an associative array as named child tags — an object;
|
|
12
|
+
* - scalars as element text (booleans were written as `"true"`/`"false"`).
|
|
13
|
+
*
|
|
14
|
+
* **XXE-safe by construction.** This is a hand-written recursive-descent parser
|
|
15
|
+
* (NOT a general XML library). It supports ONLY elements, text, comments, the XML
|
|
16
|
+
* declaration, CDATA, and the five built-in entities. It does NOT process a DOCTYPE
|
|
17
|
+
* / DTD, does NOT define or expand custom/general entities, and never resolves
|
|
18
|
+
* external entities or system identifiers — the classic XXE / billion-laughs
|
|
19
|
+
* vectors cannot occur because the machinery for them is simply absent. A DOCTYPE,
|
|
20
|
+
* a processing instruction other than the XML decl, or an unknown `&entity;`
|
|
21
|
+
* reference is rejected — entity expansion and external entity resolution don't
|
|
22
|
+
* exist here at all. HMAC verification is always computed over the raw bytes,
|
|
23
|
+
* never the parsed tree.
|
|
24
|
+
*
|
|
25
|
+
* This is intentionally small — JSON is the default wire format; XML is the opt-in
|
|
26
|
+
* alternative — and it only needs to invert the company-data payloads (dicts of
|
|
27
|
+
* lists of dicts of scalars).
|
|
28
|
+
*/
|
|
29
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
|
+
exports.XmlParseError = void 0;
|
|
31
|
+
exports.parseXml = parseXml;
|
|
32
|
+
class XmlParseError extends Error {
|
|
33
|
+
}
|
|
34
|
+
exports.XmlParseError = XmlParseError;
|
|
35
|
+
// The ONLY entities recognized — the five XML built-ins. No custom/general/external
|
|
36
|
+
// entity is ever defined or expanded (XXE-safe).
|
|
37
|
+
const BUILTIN_ENTITIES = {
|
|
38
|
+
lt: '<',
|
|
39
|
+
gt: '>',
|
|
40
|
+
amp: '&',
|
|
41
|
+
quot: '"',
|
|
42
|
+
apos: "'",
|
|
43
|
+
};
|
|
44
|
+
function decodeEntities(s) {
|
|
45
|
+
return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (_m, body) => {
|
|
46
|
+
if (body[0] === '#') {
|
|
47
|
+
const isHex = body[1] === 'x' || body[1] === 'X';
|
|
48
|
+
const codeStr = isHex ? body.slice(2) : body.slice(1);
|
|
49
|
+
const code = parseInt(codeStr, isHex ? 16 : 10);
|
|
50
|
+
if (Number.isNaN(code) || code < 0 || code > 0x10ffff) {
|
|
51
|
+
throw new XmlParseError(`invalid numeric character reference &${body};`);
|
|
52
|
+
}
|
|
53
|
+
return String.fromCodePoint(code);
|
|
54
|
+
}
|
|
55
|
+
const replacement = BUILTIN_ENTITIES[body];
|
|
56
|
+
if (replacement === undefined) {
|
|
57
|
+
// A non-builtin entity reference — reject rather than expand. This is the
|
|
58
|
+
// XXE / entity-expansion guard: we never define or look up custom entities.
|
|
59
|
+
throw new XmlParseError(`unsupported XML entity &${body}; (custom/external entities are disabled)`);
|
|
60
|
+
}
|
|
61
|
+
return replacement;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
class Parser {
|
|
65
|
+
constructor(text) {
|
|
66
|
+
this.i = 0;
|
|
67
|
+
this.s = text;
|
|
68
|
+
}
|
|
69
|
+
parse() {
|
|
70
|
+
this.skipProlog();
|
|
71
|
+
const root = this.parseElement();
|
|
72
|
+
this.skipMisc();
|
|
73
|
+
if (this.i < this.s.length) {
|
|
74
|
+
throw new XmlParseError('trailing content after the document root element');
|
|
75
|
+
}
|
|
76
|
+
return root;
|
|
77
|
+
}
|
|
78
|
+
skipWhitespace() {
|
|
79
|
+
while (this.i < this.s.length && /\s/.test(this.s[this.i]))
|
|
80
|
+
this.i++;
|
|
81
|
+
}
|
|
82
|
+
// Skip the XML declaration, comments, and whitespace BEFORE the root element.
|
|
83
|
+
// A DOCTYPE is explicitly rejected (no DTD processing — XXE-safe).
|
|
84
|
+
skipProlog() {
|
|
85
|
+
for (;;) {
|
|
86
|
+
this.skipWhitespace();
|
|
87
|
+
if (this.s.startsWith('<?xml', this.i)) {
|
|
88
|
+
this.skipUntil('?>');
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (this.s.startsWith('<!--', this.i)) {
|
|
92
|
+
this.skipUntil('-->');
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (this.s.startsWith('<!DOCTYPE', this.i) || this.s.startsWith('<!doctype', this.i)) {
|
|
96
|
+
throw new XmlParseError('DOCTYPE / DTD is not allowed (XXE-safe parser)');
|
|
97
|
+
}
|
|
98
|
+
if (this.s.startsWith('<?', this.i)) {
|
|
99
|
+
throw new XmlParseError('processing instructions are not allowed');
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Skip comments + whitespace AFTER the root element (epilogue).
|
|
105
|
+
skipMisc() {
|
|
106
|
+
for (;;) {
|
|
107
|
+
this.skipWhitespace();
|
|
108
|
+
if (this.s.startsWith('<!--', this.i)) {
|
|
109
|
+
this.skipUntil('-->');
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
skipUntil(marker) {
|
|
116
|
+
const idx = this.s.indexOf(marker, this.i);
|
|
117
|
+
if (idx === -1)
|
|
118
|
+
throw new XmlParseError(`unterminated '${marker}'`);
|
|
119
|
+
this.i = idx + marker.length;
|
|
120
|
+
}
|
|
121
|
+
parseName() {
|
|
122
|
+
const start = this.i;
|
|
123
|
+
// XML name chars (a permissive but safe subset): letters, digits, _, -, ., :
|
|
124
|
+
while (this.i < this.s.length && /[A-Za-z0-9_\-.:]/.test(this.s[this.i]))
|
|
125
|
+
this.i++;
|
|
126
|
+
if (this.i === start)
|
|
127
|
+
throw new XmlParseError(`expected an element name at offset ${this.i}`);
|
|
128
|
+
return this.s.slice(start, this.i);
|
|
129
|
+
}
|
|
130
|
+
// Skip attributes within a start tag (the platform serializer emits none, but be
|
|
131
|
+
// tolerant). Attribute VALUES are read but never define entities.
|
|
132
|
+
skipAttributes() {
|
|
133
|
+
for (;;) {
|
|
134
|
+
this.skipWhitespace();
|
|
135
|
+
const c = this.s[this.i];
|
|
136
|
+
if (c === '>' || c === '/' || c === undefined)
|
|
137
|
+
return;
|
|
138
|
+
// name
|
|
139
|
+
this.parseName();
|
|
140
|
+
this.skipWhitespace();
|
|
141
|
+
if (this.s[this.i] !== '=')
|
|
142
|
+
throw new XmlParseError('malformed attribute (expected =)');
|
|
143
|
+
this.i++; // '='
|
|
144
|
+
this.skipWhitespace();
|
|
145
|
+
const quote = this.s[this.i];
|
|
146
|
+
if (quote !== '"' && quote !== "'")
|
|
147
|
+
throw new XmlParseError('attribute value must be quoted');
|
|
148
|
+
this.i++;
|
|
149
|
+
const end = this.s.indexOf(quote, this.i);
|
|
150
|
+
if (end === -1)
|
|
151
|
+
throw new XmlParseError('unterminated attribute value');
|
|
152
|
+
this.i = end + 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
parseElement() {
|
|
156
|
+
if (this.s[this.i] !== '<')
|
|
157
|
+
throw new XmlParseError(`expected '<' at offset ${this.i}`);
|
|
158
|
+
this.i++; // '<'
|
|
159
|
+
const tag = this.parseName();
|
|
160
|
+
this.skipAttributes();
|
|
161
|
+
if (this.s.startsWith('/>', this.i)) {
|
|
162
|
+
this.i += 2; // self-closing
|
|
163
|
+
return { tag, children: [], text: '' };
|
|
164
|
+
}
|
|
165
|
+
if (this.s[this.i] !== '>')
|
|
166
|
+
throw new XmlParseError(`malformed start tag <${tag}>`);
|
|
167
|
+
this.i++; // '>'
|
|
168
|
+
const node = { tag, children: [], text: '' };
|
|
169
|
+
const textParts = [];
|
|
170
|
+
for (;;) {
|
|
171
|
+
if (this.i >= this.s.length)
|
|
172
|
+
throw new XmlParseError(`unterminated element <${tag}>`);
|
|
173
|
+
if (this.s.startsWith('</', this.i)) {
|
|
174
|
+
this.i += 2;
|
|
175
|
+
const closeName = this.parseName();
|
|
176
|
+
this.skipWhitespace();
|
|
177
|
+
if (this.s[this.i] !== '>')
|
|
178
|
+
throw new XmlParseError(`malformed end tag </${closeName}>`);
|
|
179
|
+
this.i++; // '>'
|
|
180
|
+
if (closeName !== tag) {
|
|
181
|
+
throw new XmlParseError(`mismatched end tag: </${closeName}> closing <${tag}>`);
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
if (this.s.startsWith('<!--', this.i)) {
|
|
186
|
+
this.skipUntil('-->');
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (this.s.startsWith('<![CDATA[', this.i)) {
|
|
190
|
+
const end = this.s.indexOf(']]>', this.i + 9);
|
|
191
|
+
if (end === -1)
|
|
192
|
+
throw new XmlParseError('unterminated CDATA section');
|
|
193
|
+
textParts.push(this.s.slice(this.i + 9, end)); // raw — no entity decode in CDATA
|
|
194
|
+
this.i = end + 3;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (this.s.startsWith('<!DOCTYPE', this.i) || this.s.startsWith('<!doctype', this.i)) {
|
|
198
|
+
throw new XmlParseError('DOCTYPE / DTD is not allowed (XXE-safe parser)');
|
|
199
|
+
}
|
|
200
|
+
if (this.s.startsWith('<?', this.i)) {
|
|
201
|
+
throw new XmlParseError('processing instructions are not allowed');
|
|
202
|
+
}
|
|
203
|
+
if (this.s[this.i] === '<') {
|
|
204
|
+
node.children.push(this.parseElement());
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
// Text run up to the next '<'.
|
|
208
|
+
const lt = this.s.indexOf('<', this.i);
|
|
209
|
+
const end = lt === -1 ? this.s.length : lt;
|
|
210
|
+
textParts.push(decodeEntities(this.s.slice(this.i, end)));
|
|
211
|
+
this.i = end;
|
|
212
|
+
}
|
|
213
|
+
node.text = textParts.join('');
|
|
214
|
+
return node;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function nodeToValue(node) {
|
|
218
|
+
if (node.children.length === 0) {
|
|
219
|
+
// A leaf node: its text. Callers coerce types from the known schema; we keep
|
|
220
|
+
// the raw string (booleans came over as "true"/"false").
|
|
221
|
+
return node.text;
|
|
222
|
+
}
|
|
223
|
+
// All children are <item> → an array (PHP int-keyed list).
|
|
224
|
+
if (node.children.every((c) => c.tag === 'item')) {
|
|
225
|
+
return node.children.map(nodeToValue);
|
|
226
|
+
}
|
|
227
|
+
// Otherwise an object: named tags → keys. Repeated tags collapse to a list.
|
|
228
|
+
const result = {};
|
|
229
|
+
for (const child of node.children) {
|
|
230
|
+
const value = nodeToValue(child);
|
|
231
|
+
if (child.tag in result) {
|
|
232
|
+
const existing = result[child.tag];
|
|
233
|
+
if (Array.isArray(existing)) {
|
|
234
|
+
existing.push(value);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
result[child.tag] = [existing, value];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
result[child.tag] = value;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Parse the platform's XML serialization back into JS data (XXE-safe).
|
|
248
|
+
*
|
|
249
|
+
* Mirrors the platform serializer (see the module doc). Returns the document root
|
|
250
|
+
* element's value (a `<response>` element → an object). Throws {@link XmlParseError}
|
|
251
|
+
* on malformed XML, a DOCTYPE/DTD, a processing instruction, or any non-builtin
|
|
252
|
+
* entity reference.
|
|
253
|
+
*/
|
|
254
|
+
function parseXml(text) {
|
|
255
|
+
const root = new Parser(text).parse();
|
|
256
|
+
return nodeToValue(root);
|
|
257
|
+
}
|