Skip to content

Commit 11defea

Browse files
authored
Merge pull request #468 from zerosumsum/feature/434-webhook-custom-headers
feat: support custom configured headers for webhook delivery
2 parents a00beee + 429171a commit 11defea

6 files changed

Lines changed: 154 additions & 6 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Migration: add webhook_custom_headers column to merchants
3+
*
4+
* Stores an optional JSON object of extra HTTP headers that should be
5+
* forwarded with every webhook POST for this merchant, e.g.
6+
* { "X-My-Auth": "token123", "X-Source": "stellar-pay" }
7+
*
8+
* Header names are restricted to safe ASCII characters; values must be
9+
* non-empty strings. Validation is enforced at the application layer.
10+
*/
11+
export async function up(knex) {
12+
await knex.schema.alterTable("merchants", (table) => {
13+
table
14+
.jsonb("webhook_custom_headers")
15+
.nullable()
16+
.defaultTo(null)
17+
.comment("Merchant-defined extra headers merged into webhook POSTs");
18+
});
19+
}
20+
21+
export async function down(knex) {
22+
await knex.schema.alterTable("merchants", (table) => {
23+
table.dropColumn("webhook_custom_headers");
24+
});
25+
}

backend/src/lib/request-schemas.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@ export const paymentSessionZodSchema = paymentBaseSchema
213213

214214
export const v2PaymentSessionSchema = paymentSessionZodSchema;
215215

216+
const SAFE_HEADER_NAME_RE = /^[a-zA-Z0-9\-_]+$/;
217+
216218
export const webhookSettingsSchema = z.object({
217219
webhook_url: z.preprocess(
218220
(value) => {
@@ -229,6 +231,14 @@ export const webhookSettingsSchema = z.object({
229231
.refine((val) => val.startsWith("https://"), "webhook_url must use HTTPS")
230232
.optional(),
231233
),
234+
custom_headers: z
235+
.record(z.string(), z.string().min(1, "Header value must not be empty"))
236+
.refine(
237+
(obj) => Object.keys(obj).every((k) => SAFE_HEADER_NAME_RE.test(k)),
238+
"Header names must contain only alphanumeric characters, hyphens, or underscores",
239+
)
240+
.optional()
241+
.nullable(),
232242
});
233243

234244

backend/src/lib/webhooks.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,16 +181,55 @@ function scheduleRetries(url, payload, headers, paymentId) {
181181
setTimeout(retry, RETRY_DELAYS_MS[0]);
182182
}
183183

184+
/**
185+
* Validate and sanitise a merchant-supplied custom headers object.
186+
*
187+
* Accepted: plain object whose keys are safe ASCII header names and whose
188+
* values are non-empty strings.
189+
* Reserved system headers (Content-Type, User-Agent, Stellar-Signature) are
190+
* silently dropped to prevent merchants from overriding security controls.
191+
*
192+
* @param {unknown} raw The value stored in merchants.webhook_custom_headers.
193+
* @returns {Record<string, string>} A safe subset of the supplied headers.
194+
*/
195+
export function sanitizeCustomHeaders(raw) {
196+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
197+
198+
const SAFE_HEADER_NAME = /^[a-zA-Z0-9\-_]+$/;
199+
const RESERVED = new Set([
200+
"content-type",
201+
"user-agent",
202+
"stellar-signature",
203+
]);
204+
205+
const result = {};
206+
for (const [key, value] of Object.entries(raw)) {
207+
if (!SAFE_HEADER_NAME.test(key)) continue;
208+
if (RESERVED.has(key.toLowerCase())) continue;
209+
if (typeof value !== "string" || value.trim() === "") continue;
210+
result[key] = value;
211+
}
212+
return result;
213+
}
214+
184215
/**
185216
* Sends a signed webhook POST request to `url`.
217+
*
218+
* @param {string} url Destination URL.
219+
* @param {object} payload JSON body to send.
220+
* @param {string} secret HMAC signing secret.
221+
* @param {string|null} paymentId For delivery logging.
222+
* @param {object} [customHeaders={}] Merchant-defined extra headers.
186223
*/
187-
export async function sendWebhook(url, payload, secret, paymentId = null) {
224+
export async function sendWebhook(url, payload, secret, paymentId = null, customHeaders = {}) {
188225
if (!url) return { ok: false, skipped: true };
189226

190227
const signingSecret = secret || process.env.WEBHOOK_SECRET || "";
191228
const rawBody = JSON.stringify(payload);
192229

193230
const headers = {
231+
// Merchant custom headers first so system headers always take precedence.
232+
...sanitizeCustomHeaders(customHeaders),
194233
"Content-Type": "application/json",
195234
"User-Agent": "stellar-payment-api/0.1"
196235
};

backend/src/routes/merchants.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -511,19 +511,27 @@ function createMerchantsRouter({
511511
try {
512512
const body = req.body;
513513

514+
const updatePayload = { webhook_url: body.webhook_url || null };
515+
if ("custom_headers" in body) {
516+
updatePayload.webhook_custom_headers = body.custom_headers ?? null;
517+
}
518+
514519
const { data, error } = await supabase
515520
.from("merchants")
516-
.update({ webhook_url: body.webhook_url || null })
521+
.update(updatePayload)
517522
.eq("id", req.merchant.id)
518-
.select("webhook_url")
523+
.select("webhook_url, webhook_custom_headers")
519524
.single();
520525

521526
if (error) {
522527
error.status = 500;
523528
throw error;
524529
}
525530

526-
res.json({ webhook_url: data.webhook_url || "" });
531+
res.json({
532+
webhook_url: data.webhook_url || "",
533+
custom_headers: data.webhook_custom_headers ?? {},
534+
});
527535
} catch (err) {
528536
next(err);
529537
}

backend/src/routes/payments.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ function createPaymentsRouter({
388388
let query = supabase
389389
.from("payments")
390390
.select(
391-
"id, merchant_id, amount, asset, asset_issuer, recipient, status, tx_id, memo, memo_type, webhook_url, merchants(webhook_secret, webhook_version, notification_email, email)"
391+
"id, merchant_id, amount, asset, asset_issuer, recipient, status, tx_id, memo, memo_type, webhook_url, merchants(webhook_secret, webhook_version, webhook_custom_headers, notification_email, email)"
392392
);
393393

394394
if (req.merchant?.id) {
@@ -494,7 +494,9 @@ function createPaymentsRouter({
494494
const webhookResult = await sendWebhook(
495495
data.webhook_url,
496496
webhookPayload,
497-
merchantSecret
497+
merchantSecret,
498+
data.id,
499+
data.merchants?.webhook_custom_headers ?? {}
498500
);
499501

500502
if (!webhookResult.ok && !webhookResult.skipped) {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
3+
// Stub Supabase before importing webhooks so the missing-env-var guard doesn't fire.
4+
vi.mock("../../src/lib/supabase.js", () => ({ supabase: {} }));
5+
6+
import { sanitizeCustomHeaders } from "../../src/lib/webhooks.js";
7+
8+
describe("sanitizeCustomHeaders", () => {
9+
it("returns empty object for null/undefined input", () => {
10+
expect(sanitizeCustomHeaders(null)).toEqual({});
11+
expect(sanitizeCustomHeaders(undefined)).toEqual({});
12+
});
13+
14+
it("returns empty object for non-object input", () => {
15+
expect(sanitizeCustomHeaders("string")).toEqual({});
16+
expect(sanitizeCustomHeaders([])).toEqual({});
17+
expect(sanitizeCustomHeaders(42)).toEqual({});
18+
});
19+
20+
it("passes through valid headers", () => {
21+
const result = sanitizeCustomHeaders({
22+
"X-My-Auth": "token123",
23+
"X-Source": "stellar-pay",
24+
});
25+
expect(result["X-My-Auth"]).toBe("token123");
26+
expect(result["X-Source"]).toBe("stellar-pay");
27+
});
28+
29+
it("drops headers with unsafe names", () => {
30+
const result = sanitizeCustomHeaders({
31+
"X-Valid": "ok",
32+
"Bad Header!": "should-drop",
33+
"Also Bad<>": "drop",
34+
});
35+
expect(Object.keys(result)).toEqual(["X-Valid"]);
36+
});
37+
38+
it("drops reserved system headers regardless of case", () => {
39+
const result = sanitizeCustomHeaders({
40+
"content-type": "text/plain",
41+
"User-Agent": "hacker",
42+
"STELLAR-SIGNATURE": "fake",
43+
"X-Custom": "keep",
44+
});
45+
expect(result).toEqual({ "X-Custom": "keep" });
46+
});
47+
48+
it("drops headers with empty string values", () => {
49+
const result = sanitizeCustomHeaders({
50+
"X-Empty": "",
51+
"X-Keep": "value",
52+
});
53+
expect(result).toEqual({ "X-Keep": "value" });
54+
});
55+
56+
it("drops headers with non-string values", () => {
57+
const result = sanitizeCustomHeaders({
58+
"X-Number": 123,
59+
"X-Bool": true,
60+
"X-String": "ok",
61+
});
62+
expect(result).toEqual({ "X-String": "ok" });
63+
});
64+
});

0 commit comments

Comments
 (0)