Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,6 @@ var htmx = (() => {
HCON.merge(sourceElement._htmx.boosted, ctx);
}
ctx.target = this.__resolveTarget(sourceElement, ctx.target);
ctx.request.headers["HX-Request-Type"] = (ctx.target === document.body || ctx.select) ? "full" : "partial";
if (ctx.target) {
ctx.request.headers["HX-Target"] = this.__buildIdentifier(ctx.target);
}
Expand Down Expand Up @@ -575,6 +574,8 @@ var htmx = (() => {
disableElements = this.__disableElements(elt);

ctx.fetch ||= window.fetch.bind(window)
// Set HX-Request-Type based on final target/select (after all modifications)
ctx.request.headers["HX-Request-Type"] = (ctx.target === document.body || ctx.select) ? "full" : "partial";
if (!this.__trigger(elt, "htmx:before:request", {ctx})) return;

let response = await ctx.fetch(ctx.request.action, ctx.request);
Expand Down
50 changes: 48 additions & 2 deletions test/tests/end2end/basic-history.js
Original file line number Diff line number Diff line change
Expand Up @@ -511,10 +511,56 @@ describe('scroll restoration on history traversal', function() {
});
});

describe('HX-Request-Type header in history restore', function() {

beforeEach(function() { setupTest(); });
afterEach(function() { cleanupTest(); });

it('sends HX-Request-Type: full for history restore to body', async function() {
mockResponse('GET', '/restore-test', '<div>restored</div>', { headers: { 'HX-Reswap': 'none' } });

htmx.__restoreHistory({htmx: true}, '/restore-test');
await forRequest();

assert.equal(lastFetch().request.headers['HX-Request-Type'], 'full');
});

it('sends HX-Request-Type: full for history restore with hx-history-elt', async function() {
playground().innerHTML = '<main hx-history-elt><p>old</p></main>';
htmx.process(playground());

mockResponse('GET', '/restore-test', '<html><body><main hx-history-elt><p>new</p></main></body></html>', { headers: { 'HX-Reswap': 'none' } });

htmx.__restoreHistory({htmx: true}, '/restore-test');
await forRequest();

assert.equal(lastFetch().request.headers['HX-Request-Type'], 'full');
});

it('sends HX-History-Restore-Request: true for history restore', async function() {
mockResponse('GET', '/restore-test', '<div>restored</div>', { headers: { 'HX-Reswap': 'none' } });

htmx.__restoreHistory({htmx: true}, '/restore-test');
await forRequest();

assert.equal(lastFetch().request.headers['HX-History-Restore-Request'], 'true');
});

it('does not send HX-Request header for history restore (so servers return full pages)', async function() {
mockResponse('GET', '/restore-test', '<div>restored</div>', { headers: { 'HX-Reswap': 'none' } });

htmx.__restoreHistory({htmx: true}, '/restore-test');
await forRequest();

// HX-Request is intentionally omitted so servers return full pages
assert.isUndefined(lastFetch().request.headers['HX-Request']);
});
});

describe('history restore edge cases', function() {

beforeEach(() => { setupTest(this.currentTest); });
afterEach(() => { cleanupTest(); });
beforeEach(function() { setupTest(); });
afterEach(function() { cleanupTest(); });

it('a second back aborts the in-flight restore', async function() {
this.timeout(5000);
Expand Down
46 changes: 26 additions & 20 deletions test/tests/unit/headers.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,39 +65,45 @@ describe('Request Headers', function() {
describe('HX-Request-Type header', function() {

it('sets to partial for regular element target', async function() {
createProcessedHTML('<div id="result"></div><button hx-get="js:" hx-target="#result"></button>');
mockResponse('GET', '/test', 'ok');
createProcessedHTML('<div id="result"></div><button hx-get="/test" hx-target="#result"></button>');
let btn = document.querySelector('button');
let ctx = htmx.__createRequestContext(btn, new Event('click'));
await htmx.__handleTriggerEvent(ctx);
ctx.request.headers['HX-Request-Type'].should.equal('partial');
btn.click();
await forRequest();
lastFetch().request.headers['HX-Request-Type'].should.equal('partial');
});

it('sets to partial when targeting self', async function() {
let btn = createProcessedHTML('<button hx-get="js:"></button>');
let ctx = htmx.__createRequestContext(btn, new Event('click'));
await htmx.__handleTriggerEvent(ctx);
ctx.request.headers['HX-Request-Type'].should.equal('partial');
mockResponse('GET', '/test', 'ok');
let btn = createProcessedHTML('<button hx-get="/test"></button>');
btn.click();
await forRequest();
lastFetch().request.headers['HX-Request-Type'].should.equal('partial');
});

it('sets to full when targeting body', async function() {
let btn = createProcessedHTML('<button hx-get="js:" hx-target="body"></button>');
let ctx = htmx.__createRequestContext(btn, new Event('click'));
await htmx.__handleTriggerEvent(ctx);
ctx.request.headers['HX-Request-Type'].should.equal('full');
mockResponse('GET', '/test', 'ok');
let btn = createProcessedHTML('<button hx-get="/test" hx-target="body" hx-swap="none"></button>');
btn.click();
await forRequest();
lastFetch().request.headers['HX-Request-Type'].should.equal('full');
});

it('sets to full when hx-select is present', async function() {
let btn = createProcessedHTML('<button hx-get="js:" hx-select="#content"></button>');
let ctx = htmx.__createRequestContext(btn, new Event('click'));
await htmx.__handleTriggerEvent(ctx);
ctx.request.headers['HX-Request-Type'].should.equal('full');
mockResponse('GET', '/test', '<div id="content">ok</div>');
createProcessedHTML('<div id="result"></div><button hx-get="/test" hx-target="#result" hx-select="#content"></button>');
let btn = document.querySelector('button');
btn.click();
await forRequest();
lastFetch().request.headers['HX-Request-Type'].should.equal('full');
});

it('sets to full when hx-select and body target both present', async function() {
let btn = createProcessedHTML('<button hx-get="js:" hx-target="body" hx-select="#content"></button>');
let ctx = htmx.__createRequestContext(btn, new Event('click'));
await htmx.__handleTriggerEvent(ctx);
ctx.request.headers['HX-Request-Type'].should.equal('full');
mockResponse('GET', '/test', '<div id="content">ok</div>');
let btn = createProcessedHTML('<button hx-get="/test" hx-target="body" hx-select="#content" hx-swap="none"></button>');
btn.click();
await forRequest();
lastFetch().request.headers['HX-Request-Type'].should.equal('full');
});

});
Expand Down
13 changes: 13 additions & 0 deletions www/src/content/extensions/14-hx-csp.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,19 @@ The server cannot know the page nonce — it only knows its own per-response non

The risk: unlike `<script nonce>`, `hx-nonce` attributes are not blanked by browsers after parse, so they are a possible additional nonce exposure surface. The scrub step is a defence-in-depth measure to ensure a stolen nonce cannot be pre-stamped into injected content to pass nonce checks.

## Caching Nonced Responses

If you cache nonced responses, you must ensure that the initial page load and subsequent htmx requests are cached separately. Otherwise, the nonce reuse protection will block legitimate responses — when a user navigates back to a cached page via history restore, the cached response will contain the same nonce as the current page, triggering the scrub step.

htmx sends an `HX-Request-Type` header with every request (`full` or `partial`). Add `Vary: HX-Request-Type` to separate the cache:

```http
Cache-Control: public, max-age=3600
Vary: HX-Request-Type
```

This ensures initial page loads and htmx requests are cached separately, so the response nonce will always differ from the page nonce.

## Inline Scripts in Swapped Content

When htmx swaps in HTML containing `<script>` tags, it re-creates them to trigger execution. The `hx-csp` extension ensures the response nonce is rewritten to the page nonce before parsing, so script nonces are correctly promoted and execute under a strict `script-src 'nonce-<nonce>'` policy.
Expand Down
Loading