@@ -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 - z A - Z 0 - 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 } ;
0 commit comments