Skip to content

Commit 7389e03

Browse files
manwithacatclaude
andcommitted
fix: prevent partial response-header match TypeError (#527)
hasHeader() tested a regex against getAllResponseHeaders() output, which false-positively matched any header containing an htmx header name as a substring — e.g. X-HX-Trigger or HX-Trigger-User. After the false-positive, xhr.getResponseHeader('HX-Trigger') returned null, and handleTriggerHeader crashed on triggerBody.indexOf('{'). Refactors hasHeader(xhr, regex) → hasHeader(xhr, name), using getResponseHeader(name) !== null as the check. This is the exact check the 12 call sites need, eliminates the substring-match class of bug, and removes a layer of indirection (no more regex construction or getAllResponseHeaders() scan per check). Adds a regression test with X-HX-Trigger: foo that previously crashed inside handleTriggerHeader. Fixes #527. Addresses @1cg's suggestion in that thread. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d53932d commit 7389e03

2 files changed

Lines changed: 31 additions & 15 deletions

File tree

src/htmx.js

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4039,11 +4039,11 @@ var htmx = (function() {
40394039

40404040
/**
40414041
* @param {XMLHttpRequest} xhr
4042-
* @param {RegExp} regexp
4042+
* @param {string} name
40434043
* @return {boolean}
40444044
*/
4045-
function hasHeader(xhr, regexp) {
4046-
return regexp.test(xhr.getAllResponseHeaders())
4045+
function hasHeader(xhr, name) {
4046+
return xhr.getResponseHeader(name) !== null
40474047
}
40484048

40494049
/**
@@ -4668,13 +4668,13 @@ var htmx = (function() {
46684668
//= ==========================================
46694669
let pathFromHeaders = null
46704670
let typeFromHeaders = null
4671-
if (hasHeader(xhr, /HX-Push:/i)) {
4671+
if (hasHeader(xhr, 'HX-Push')) {
46724672
pathFromHeaders = xhr.getResponseHeader('HX-Push')
46734673
typeFromHeaders = 'push'
4674-
} else if (hasHeader(xhr, /HX-Push-Url:/i)) {
4674+
} else if (hasHeader(xhr, 'HX-Push-Url')) {
46754675
pathFromHeaders = xhr.getResponseHeader('HX-Push-Url')
46764676
typeFromHeaders = 'push'
4677-
} else if (hasHeader(xhr, /HX-Replace-Url:/i)) {
4677+
} else if (hasHeader(xhr, 'HX-Replace-Url')) {
46784678
pathFromHeaders = xhr.getResponseHeader('HX-Replace-Url')
46794679
typeFromHeaders = 'replace'
46804680
}
@@ -4809,11 +4809,11 @@ var htmx = (function() {
48094809

48104810
if (!triggerEvent(elt, 'htmx:beforeOnLoad', responseInfo)) return
48114811

4812-
if (hasHeader(xhr, /HX-Trigger:/i)) {
4812+
if (hasHeader(xhr, 'HX-Trigger')) {
48134813
handleTriggerHeader(xhr, 'HX-Trigger', elt)
48144814
}
48154815

4816-
if (hasHeader(xhr, /HX-Location:/i)) {
4816+
if (hasHeader(xhr, 'HX-Location')) {
48174817
let redirectPath = xhr.getResponseHeader('HX-Location')
48184818
/** @type {HtmxAjaxHelperContext&{path?:string}} */
48194819
var redirectSwapSpec = {}
@@ -4828,9 +4828,9 @@ var htmx = (function() {
48284828
return
48294829
}
48304830

4831-
const shouldRefresh = hasHeader(xhr, /HX-Refresh:/i) && xhr.getResponseHeader('HX-Refresh') === 'true'
4831+
const shouldRefresh = hasHeader(xhr, 'HX-Refresh') && xhr.getResponseHeader('HX-Refresh') === 'true'
48324832

4833-
if (hasHeader(xhr, /HX-Redirect:/i)) {
4833+
if (hasHeader(xhr, 'HX-Redirect')) {
48344834
responseInfo.keepIndicators = true
48354835
htmx.location.href = xhr.getResponseHeader('HX-Redirect')
48364836
shouldRefresh && htmx.location.reload()
@@ -4859,11 +4859,11 @@ var htmx = (function() {
48594859
}
48604860

48614861
// response headers override response handling config
4862-
if (hasHeader(xhr, /HX-Retarget:/i)) {
4862+
if (hasHeader(xhr, 'HX-Retarget')) {
48634863
responseInfo.target = resolveRetarget(elt, xhr.getResponseHeader('HX-Retarget'))
48644864
}
48654865

4866-
if (hasHeader(xhr, /HX-Reswap:/i)) {
4866+
if (hasHeader(xhr, 'HX-Reswap')) {
48674867
swapOverride = xhr.getResponseHeader('HX-Reswap')
48684868
}
48694869

@@ -4919,7 +4919,7 @@ var htmx = (function() {
49194919
selectOverride = responseInfoSelect
49204920
}
49214921

4922-
if (hasHeader(xhr, /HX-Reselect:/i)) {
4922+
if (hasHeader(xhr, 'HX-Reselect')) {
49234923
selectOverride = xhr.getResponseHeader('HX-Reselect')
49244924
}
49254925

@@ -4933,7 +4933,7 @@ var htmx = (function() {
49334933
anchor: responseInfo.pathInfo.anchor,
49344934
contextElement: elt,
49354935
afterSwapCallback: function() {
4936-
if (hasHeader(xhr, /HX-Trigger-After-Swap:/i)) {
4936+
if (hasHeader(xhr, 'HX-Trigger-After-Swap')) {
49374937
let finalElt = elt
49384938
if (!bodyContains(elt)) {
49394939
finalElt = getDocument().body
@@ -4942,7 +4942,7 @@ var htmx = (function() {
49424942
}
49434943
},
49444944
afterSettleCallback: function() {
4945-
if (hasHeader(xhr, /HX-Trigger-After-Settle:/i)) {
4945+
if (hasHeader(xhr, 'HX-Trigger-After-Settle')) {
49464946
let finalElt = elt
49474947
if (!bodyContains(elt)) {
49484948
finalElt = getDocument().body

test/core/headers.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,4 +533,20 @@ describe('Core htmx AJAX headers', function() {
533533
htmx._('loadHistoryFromServer')('/test')
534534
this.server.respond()
535535
})
536+
537+
// Regression for #527: response headers whose names *contain* an htmx
538+
// header name as a substring (e.g. X-HX-Trigger) must not be treated as
539+
// the htmx header. The previous implementation tested a regex against
540+
// getAllResponseHeaders() and false-positively matched the substring,
541+
// then crashed when getResponseHeader returned null.
542+
it('does not crash or fire trigger when X-HX-Trigger header is present without HX-Trigger', function() {
543+
this.server.respondWith('GET', '/test', [200, { 'X-HX-Trigger': 'foo' }, ''])
544+
545+
var div = make('<div hx-get="/test"></div>')
546+
var invokedEvent = false
547+
div.addEventListener('foo', function() { invokedEvent = true })
548+
div.click()
549+
this.server.respond()
550+
invokedEvent.should.equal(false)
551+
})
536552
})

0 commit comments

Comments
 (0)