diff --git a/docs/articles/antiforgery.md b/docs/articles/antiforgery.md index f123bec..a05fd62 100644 --- a/docs/articles/antiforgery.md +++ b/docs/articles/antiforgery.md @@ -11,7 +11,7 @@ see [Static assets and deployment](static-assets.md) for the URL and caching. | Feature | HTMX versions | Without the script | |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------| -| Adds the antiforgery token to non-GET HTMX requests and refreshes it after boosted navigation | All | Non-GET requests without a token field fail server-side antiforgery validation, usually with HTTP 400 | +| Adds the antiforgery token to non-GET HTMX requests to the page's origin and refreshes it after boosted navigation | All | Non-GET requests without a token field fail server-side antiforgery validation, usually with HTTP 400 | | Sends `HX-Request-Type`; see [Check full or partial requests](requests.md#check-full-or-partial-requests) | 1.9.x and 2.x; HTMX 4.x sends it itself | `IsHtmxPartialRequest()` and `IsHtmxFullRequest()` return `false`, and `[HtmxRequest]` with a `RequestType` does not match | | Replays events whose name repeats in one response header; see [Timing and repeated events](responses.md#timing-and-repeated-events) | All | None of the events in that header fire | | Adds the `ramstack-swap` extension with `innerMorph`, `outerMorph`, and a simplified `outerSync`, plus `textContent` for HTMX 1.9.x; see [Morph swaps](version-compatibility.md#morph-swaps) | 1.9.x and 2.x; HTMX 4.x supports these swap styles natively | These swap styles are not available | @@ -21,7 +21,7 @@ see [Static assets and deployment](static-assets.md) for the URL and caching. 1. `` asks ASP.NET Core for an antiforgery token. 2. The Tag Helper writes the request token, header name, and form-field name as data attributes on ``. 3. The Toolkit script reads those attributes during page load. -4. Before each non-GET HTMX request, the script adds the token unless the form data already contains the configured antiforgery form field. +4. Before each non-GET HTMX request to the page's origin, the script adds the token unless the form data already contains the configured antiforgery form field. 5. The application validates the token through Razor Pages, an MVC filter, or its Minimal API validation setup. The token is sent as the configured antiforgery header when a header name is available. @@ -116,6 +116,24 @@ Keep `` in full document layouts. After signing in, signing out, obtain a token for the new identity. A full navigation with `HX-Redirect` is a straightforward way to do that; see [Redirect after authentication](recipes.md#redirect-after-authentication). Keeping an old token does not refresh it. +## Requests to other origins + +The script adds the token only to requests to the page's origin. Requests to another origin, including another +subdomain of the same site, do not receive it, so the token cannot leak to a third party. +If another origin of the application validates the same antiforgery tokens, send the token explicitly: + +```razor +@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery + + +``` + +Such a request also needs CORS with credentials on the other origin, and the antiforgery cookie must be +available to it. + ## Static web assets and caching Script loading, versioned URLs, and deployment are covered in [Static assets and deployment](static-assets.md). diff --git a/docs/articles/migrate-to-v2.md b/docs/articles/migrate-to-v2.md index 3d7f5b1..e5e0ba2 100644 --- a/docs/articles/migrate-to-v2.md +++ b/docs/articles/migrate-to-v2.md @@ -18,7 +18,7 @@ step; see [Version compatibility](version-compatibility.md) for that. |---------------------------------------------------------------------|-------------------------------------------------------------------------------------|-------------------| | [Registration](#register-the-toolkit-and-select-the-htmx-version) | Call `AddHtmxToolkit()` and select the HTMX version; HTMX 2.x is the default | **Silent** | | [Global configuration](#move-global-configuration-to-code) | `` attributes move to `AddHtmxToolkit()` | **Silent** | -| [Antiforgery](#antiforgery-metadata-is-on-by-default) | Metadata is included by default and no longer stored in `htmx.config` | **Silent** | +| [Antiforgery](#antiforgery-metadata-is-on-by-default) | Metadata on by default, moved out of `htmx.config`; token sent to own origin only | **Silent** | | [Toolkit script](#load-the-toolkit-script-as-a-static-asset) | Inline script helpers and `MapHtmxAntiforgeryScript` replaced by a static web asset | Build | | [URL Tag Helper](#rename-the-url-tag-helper-attributes) | `hx-*` URL attributes become `asp-*`; `hx-host` and `hx-protocol` are removed | **Silent** | | [Requests](#update-request-handling) | `IsHtmxRequest()` is `false` for history restoration; `Boosted` filter renamed | Build, **silent** | @@ -156,6 +156,10 @@ const { antiforgeryRequestToken, antiforgeryHeaderName } = document.querySelector("meta[name='htmx-config']").dataset; ``` +The Toolkit script now adds the token only to requests to the page's origin. In 1.x, it added the token +to every non-GET request, including requests to other origins. If another origin of the application validates +the same tokens, send the token explicitly; see [Requests to other origins](antiforgery.md#requests-to-other-origins). + See [Antiforgery and Toolkit script](antiforgery.md). ## Load the Toolkit script as a static asset diff --git a/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.js b/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.js index b65fe9d..cf5eb38 100644 --- a/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.js +++ b/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.js @@ -93,8 +93,21 @@ document._r_htmx ||= ((document, htmx) => { let antiforgery = read_antiforgery(document); - const add_antiforgery = (method, headers, parameters) => { - if (!/^get$/i.test(method)) { + // XHR and fetch resolve a relative URL against the document base URL. + // A URL that cannot be parsed is treated as another origin. + const is_same_origin = url => { + try { + return new URL(url, document.baseURI).origin === location.origin; + } + catch { + return false; + } + }; + + // The token is sent only to the origin of the page; another origin, even a subdomain + // of the same site, must not receive it. + const add_antiforgery = (method, url, headers, parameters) => { + if (!/^get$/i.test(method) && is_same_origin(url)) { const { headerName, formFieldName, @@ -136,12 +149,12 @@ document._r_htmx ||= ((document, htmx) => { listen("htmx:configRequest", e => { let detail = e.detail; - add_antiforgery(detail.verb, detail.headers, detail.parameters); + add_antiforgery(detail.verb, detail.path, detail.headers, detail.parameters); }); listen("htmx:config:request", e => { let request = e.detail.ctx.request; - add_antiforgery(request.method, request.headers, request.body); + add_antiforgery(request.method, request.action, request.headers, request.body); }); // HTMX reads hx-request from the element and its ancestors; the nearest value wins. diff --git a/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.min.js b/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.min.js index 73ea922..46d3d55 100644 --- a/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.min.js +++ b/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.min.js @@ -1 +1 @@ -document._r_htmx||=((e,t)=>{const r=e=>console.warn(`ramstack.htmxtoolkit: ${e}`),a=parseInt(t.version);let n;const o=e=>{let t=new DocumentFragment;for(;e.firstChild;)t.append(e.firstChild);return t};t.defineExtension&&t.defineExtension("ramstack-swap",{init(e){n=e},isInlineSwap:e=>"outerMorph"===e||"outerSync"===e,handleSwap(e,t,a){if("textContent"===e)return t.textContent=a.textContent,[t];let n="innerMorph"===e?"innerHTML":"outerMorph"===e?"outerHTML":null;if(n){let l=globalThis.Idiomorph;if(l?.morph)return l.morph(t,a.children,{morphStyle:n});r(`Idiomorph is unavailable; falling back from ${e} to ${n}`);let i=[...a.childNodes],s=o(a);return"innerHTML"===n?t.replaceChildren(s):t.replaceWith(s),i}if("outerSync"===e){r("outerSync requires HTMX 4.x; falling back to attribute sync and innerHTML");let e=a.firstElementChild;if(e){for(let r of[...t.attributes])e.hasAttribute(r.name)||t.removeAttribute(r.name);for(let r of e.attributes)t.setAttribute(r.name,r.value);return t.replaceChildren(o(e)),[t]}}}});const l=(t,r)=>{e.addEventListener(t,r)},i=e=>{let t=e.querySelector("meta[name='htmx-config']")?.dataset||{};return{headerName:t.antiforgeryHeaderName,formFieldName:t.antiforgeryFormFieldName,requestToken:t.antiforgeryRequestToken}};let s=i(e);const u=(e,t,r)=>{if(!/^get$/i.test(e)){const{headerName:e,formFieldName:a,requestToken:n}=s;n&&(r.has?.(a)||r[a]||(e?t[e]=n:r.set?r.set(a,n):r[a]=n))}},d=e=>{let t=(new DOMParser).parseFromString(e||"","text/html"),r=i(t);r.requestToken&&(s=r)};return l("htmx:afterOnLoad",e=>{let t=e.detail;t.boosted&&d(t.xhr.responseText)}),l("htmx:after:request",e=>{let t=e.detail.ctx;t.boosted&&d(t.text)}),l("htmx:configRequest",e=>{let t=e.detail;u(t.verb,t.headers,t.parameters)}),l("htmx:config:request",e=>{let t=e.detail.ctx.request;u(t.method,t.headers,t.body)}),l("htmx:beforeRequest",t=>{if(4>a){let r=t.target,{xhr:a,target:o,select:l,requestConfig:i}=t.detail;null!=i.headers["HX-Request-Type"]||(e=>{let t=!1;return n.getClosestMatch(e,e=>{let r=n.getAttributeValue(e,"hx-request")?.trim();if(r&&!/^(js|javascript):/.test(r))try{let{noHeaders:e}=JSON.parse("{"===r[0]?r:`{${r}}`);if(null!=e)return t=!!e,!0}catch{}}),t})(r)||(l||=n.getClosestAttributeValue(r,"hx-select"),a.setRequestHeader("HX-Request-Type",o===e.body||l&&"unset"!==l?"full":"partial"))}}),l("rs:event",e=>{let r=e.target;for(let{key:n,value:o}of e.detail.value||e.detail){let e=r;4>a?"[object Object]"===Object.prototype.toString.call(o)?2===a&&void 0!==o.target&&(r=e=o.target):o={value:o}:(o?.target&&(e=t.find(o.target)),"object"!=typeof o&&(o={value:o})),t.trigger(e,n,o)}}),!0})(document,htmx); \ No newline at end of file +document._r_htmx||=((e,t)=>{const r=e=>console.warn(`ramstack.htmxtoolkit: ${e}`),n=parseInt(t.version);let a;const o=e=>{let t=new DocumentFragment;for(;e.firstChild;)t.append(e.firstChild);return t};t.defineExtension&&t.defineExtension("ramstack-swap",{init(e){a=e},isInlineSwap:e=>"outerMorph"===e||"outerSync"===e,handleSwap(e,t,n){if("textContent"===e)return t.textContent=n.textContent,[t];let a="innerMorph"===e?"innerHTML":"outerMorph"===e?"outerHTML":null;if(a){let i=globalThis.Idiomorph;if(i?.morph)return i.morph(t,n.children,{morphStyle:a});r(`Idiomorph is unavailable; falling back from ${e} to ${a}`);let l=[...n.childNodes],s=o(n);return"innerHTML"===a?t.replaceChildren(s):t.replaceWith(s),l}if("outerSync"===e){r("outerSync requires HTMX 4.x; falling back to attribute sync and innerHTML");let e=n.firstElementChild;if(e){for(let r of[...t.attributes])e.hasAttribute(r.name)||t.removeAttribute(r.name);for(let r of e.attributes)t.setAttribute(r.name,r.value);return t.replaceChildren(o(e)),[t]}}}});const i=(t,r)=>{e.addEventListener(t,r)},l=e=>{let t=e.querySelector("meta[name='htmx-config']")?.dataset||{};return{headerName:t.antiforgeryHeaderName,formFieldName:t.antiforgeryFormFieldName,requestToken:t.antiforgeryRequestToken}};let s=l(e);const u=(t,r,n,a)=>{if(!/^get$/i.test(t)&&(t=>{try{return new URL(t,e.baseURI).origin===location.origin}catch{return!1}})(r)){const{headerName:e,formFieldName:t,requestToken:r}=s;r&&(a.has?.(t)||a[t]||(e?n[e]=r:a.set?a.set(t,r):a[t]=r))}},d=e=>{let t=(new DOMParser).parseFromString(e||"","text/html"),r=l(t);r.requestToken&&(s=r)};return i("htmx:afterOnLoad",e=>{let t=e.detail;t.boosted&&d(t.xhr.responseText)}),i("htmx:after:request",e=>{let t=e.detail.ctx;t.boosted&&d(t.text)}),i("htmx:configRequest",e=>{let t=e.detail;u(t.verb,t.path,t.headers,t.parameters)}),i("htmx:config:request",e=>{let t=e.detail.ctx.request;u(t.method,t.action,t.headers,t.body)}),i("htmx:beforeRequest",t=>{if(4>n){let r=t.target,{xhr:n,target:o,select:i,requestConfig:l}=t.detail;null!=l.headers["HX-Request-Type"]||(e=>{let t=!1;return a.getClosestMatch(e,e=>{let r=a.getAttributeValue(e,"hx-request")?.trim();if(r&&!/^(js|javascript):/.test(r))try{let{noHeaders:e}=JSON.parse("{"===r[0]?r:`{${r}}`);if(null!=e)return t=!!e,!0}catch{}}),t})(r)||(i||=a.getClosestAttributeValue(r,"hx-select"),n.setRequestHeader("HX-Request-Type",o===e.body||i&&"unset"!==i?"full":"partial"))}}),i("rs:event",e=>{let r=e.target;for(let{key:a,value:o}of e.detail.value||e.detail){let e=r;4>n?"[object Object]"===Object.prototype.toString.call(o)?2===n&&void 0!==o.target&&(r=e=o.target):o={value:o}:(o?.target&&(e=t.find(o.target)),"object"!=typeof o&&(o={value:o})),t.trigger(e,a,o)}}),!0})(document,htmx); \ No newline at end of file