diff --git a/adev/src/content/guide/http/security.md b/adev/src/content/guide/http/security.md
index 5ba432eba06..fbb283d11b1 100644
--- a/adev/src/content/guide/http/security.md
+++ b/adev/src/content/guide/http/security.md
@@ -18,10 +18,10 @@ A common technique to prevent XSSI is to serve JSON responses with a "non-execut
`HttpClient` supports a [common mechanism](https://en.wikipedia.org/wiki/Cross-site_request_forgery#Cookie-to-header_token) used to prevent XSRF attacks. When performing HTTP requests, an interceptor reads a token from a cookie, by default `XSRF-TOKEN`, and sets it as an HTTP header, `X-XSRF-TOKEN`. Because only code that runs on your domain could read the cookie, the backend can be certain that the HTTP request came from your client application and not an attacker.
-By default, an interceptor sends this header on all mutating requests (such as `POST`) to relative URLs, but not on GET/HEAD requests or on requests with an absolute URL.
+By default, an interceptor sends this header on all mutating requests (such as `POST`) to relative and same origin URLs, but not on `GET` or `HEAD` requests.
-CSRF protection is only needed for requests that can change state on the backend. By their nature, CSRF attacks cross domain boundaries, and the web's [same-origin policy](https://developer.mozilla.org/docs/Web/Security/Same-origin_policy) will prevent an attacking page from retrieving the results of authenticated GET requests.
+CSRF protection is only needed for requests that can change state on the backend. By their nature, CSRF attacks cross domain boundaries, and the web's [same-origin policy](https://developer.mozilla.org/docs/Web/Security/Same-origin_policy) will prevent an attacking page from retrieving the results of authenticated `GET` requests.
To take advantage of this, your server needs to set a token in a JavaScript readable session cookie called `XSRF-TOKEN` on either the page load or the first GET request. On subsequent requests the server can verify that the cookie matches the `X-XSRF-TOKEN` HTTP header, and therefore be sure that only code running on your domain could have sent the request. The token must be unique for each user and must be verifiable by the server; this prevents the client from making up its own tokens. Set the token to a digest of your site's authentication cookie with a salt for added security.
diff --git a/adev/src/content/guide/security.md b/adev/src/content/guide/security.md
index b5ce438c769..c0ca220f57e 100644
--- a/adev/src/content/guide/security.md
+++ b/adev/src/content/guide/security.md
@@ -299,10 +299,10 @@ The malicious code on `evil.com` can't.
`HttpClient` supports a [common mechanism](https://en.wikipedia.org/wiki/Cross-site_request_forgery#Cookie-to-header_token) used to prevent XSRF attacks. When performing HTTP requests, an interceptor reads a token from a cookie, by default `XSRF-TOKEN`, and sets it as an HTTP header, `X-XSRF-TOKEN`. Because only code that runs on your domain could read the cookie, the backend can be certain that the HTTP request came from your client application and not an attacker.
-By default, an interceptor sends this header on all mutating requests (such as `POST`) to relative URLs, but not on GET/HEAD requests or on requests with an absolute URL.
+By default, an interceptor sends this header on all mutating requests (such as `POST`) to relative and same origin URLs, but not on `GET` or `HEAD` requests.
-CSRF protection is only needed for requests that can change state on the backend. By their nature, CSRF attacks cross domain boundaries, and the web's [same-origin policy](https://developer.mozilla.org/docs/Web/Security/Same-origin_policy) will prevent an attacking page from retrieving the results of authenticated GET requests.
+CSRF protection is only needed for requests that can change state on the backend. By their nature, CSRF attacks cross domain boundaries, and the web's [same-origin policy](https://developer.mozilla.org/docs/Web/Security/Same-origin_policy) will prevent an attacking page from retrieving the results of authenticated `GET` requests.
To take advantage of this, your server needs to set a token in a JavaScript readable session cookie called `XSRF-TOKEN` on either the page load or the first GET request. On subsequent requests the server can verify that the cookie matches the `X-XSRF-TOKEN` HTTP header, and therefore be sure that only code running on your domain could have sent the request. The token must be unique for each user and must be verifiable by the server; this prevents the client from making up its own tokens. Set the token to a digest of your site's authentication cookie with a salt for added security.
diff --git a/packages/common/http/src/xsrf.ts b/packages/common/http/src/xsrf.ts
index f0aa4c14227..c736ffe8599 100644
--- a/packages/common/http/src/xsrf.ts
+++ b/packages/common/http/src/xsrf.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import {DOCUMENT, ɵparseCookieValue as parseCookieValue} from '../../index';
+import {DOCUMENT, ɵparseCookieValue as parseCookieValue, PlatformLocation} from '../../index';
import {
EnvironmentInjector,
inject,
@@ -91,25 +91,28 @@ export abstract class HttpXsrfTokenExtractor {
abstract getToken(): string | null;
}
-/**
- * Regex to match absolute URLs, including protocol-relative URLs.
- */
-const ABSOLUTE_URL_REGEX = /^(?:https?:)?\/\//i;
-
export function xsrfInterceptorFn(
req: HttpRequest,
next: HttpHandlerFn,
): Observable> {
- // Skip both non-mutating requests and absolute URLs.
- // Non-mutating requests don't require a token, and absolute URLs require special handling
- // anyway as the cookie set
- // on our origin is not the same as the token expected by another origin.
- if (
- !inject(XSRF_ENABLED) ||
- req.method === 'GET' ||
- req.method === 'HEAD' ||
- ABSOLUTE_URL_REGEX.test(req.url)
- ) {
+ // Skip both non-mutating requests
+ // Non-mutating requests generally don't require a token.
+ if (!inject(XSRF_ENABLED) || req.method === 'GET' || req.method === 'HEAD') {
+ return next(req);
+ }
+
+ try {
+ const locationHref = inject(PlatformLocation).href;
+ const {origin: locationOrigin} = new URL(locationHref);
+ // We can use `new URL` to normalize a relative URL like '//something.com' to
+ // 'https://something.com' in order to make consistent same-origin comparisons.
+ const {origin: requestOrigin} = new URL(req.url, locationOrigin);
+
+ if (locationOrigin !== requestOrigin) {
+ return next(req);
+ }
+ } catch {
+ // Handle invalid URLs gracefully.
return next(req);
}
diff --git a/packages/common/http/test/BUILD.bazel b/packages/common/http/test/BUILD.bazel
index efebafb741a..d99d6c3f807 100644
--- a/packages/common/http/test/BUILD.bazel
+++ b/packages/common/http/test/BUILD.bazel
@@ -14,6 +14,7 @@ ts_project(
"//packages/common",
"//packages/common/http",
"//packages/common/http/testing",
+ "//packages/common/testing",
"//packages/core",
"//packages/core/testing",
"//packages/private/testing",
diff --git a/packages/common/http/test/xsrf_spec.ts b/packages/common/http/test/xsrf_spec.ts
index d0fd908ff70..ef0d6236fb9 100644
--- a/packages/common/http/test/xsrf_spec.ts
+++ b/packages/common/http/test/xsrf_spec.ts
@@ -6,7 +6,8 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import {DOCUMENT} from '../..';
+import {MockPlatformLocation} from '@angular/common/testing';
+import {DOCUMENT, PlatformLocation} from '../..';
import {HttpHeaders} from '../src/headers';
import {HttpRequest} from '../src/request';
import {
@@ -47,24 +48,35 @@ describe('HttpXsrfInterceptor', () => {
provide: XSRF_ENABLED,
useValue: true,
},
+ {
+ provide: PlatformLocation,
+ useFactory: () =>
+ new MockPlatformLocation({
+ startUrl: 'http://sub.example.com/',
+ }),
+ },
HttpXsrfInterceptor,
],
});
+
interceptor = TestBed.inject(HttpXsrfInterceptor);
backend = new HttpClientTestingBackend();
});
+
it('applies XSRF protection to outgoing requests', () => {
interceptor.intercept(new HttpRequest('POST', '/test', {}), backend).subscribe();
const req = backend.expectOne('/test');
expect(req.request.headers.get('X-XSRF-TOKEN')).toEqual('test');
req.flush({});
});
+
it('does not apply XSRF protection when request is a GET', () => {
interceptor.intercept(new HttpRequest('GET', '/test'), backend).subscribe();
const req = backend.expectOne('/test');
expect(req.request.headers.has('X-XSRF-TOKEN')).toEqual(false);
req.flush({});
});
+
it('does not apply XSRF protection when request is a HEAD', () => {
interceptor.intercept(new HttpRequest('HEAD', '/test'), backend).subscribe();
const req = backend.expectOne('/test');
@@ -72,7 +84,7 @@ describe('HttpXsrfInterceptor', () => {
req.flush({});
});
- it('does not apply XSRF protection when request is absolute', () => {
+ it('does not apply XSRF protection when request is absolute and cross-origin', () => {
interceptor
.intercept(new HttpRequest('POST', 'https://example.com/test', {}), backend)
.subscribe();
@@ -81,13 +93,31 @@ describe('HttpXsrfInterceptor', () => {
req.flush({});
});
- it('does not apply XSRF protection when request is protocol relative', () => {
+ it('does not apply XSRF protection when request is protocol relative and cross-origin', () => {
interceptor.intercept(new HttpRequest('POST', '//example.com/test', {}), backend).subscribe();
const req = backend.expectOne('//example.com/test');
expect(req.request.headers.has('X-XSRF-TOKEN')).toBeFalse();
req.flush({});
});
+ it('does apply XSRF protection when request is same-origin', () => {
+ interceptor
+ .intercept(new HttpRequest('POST', 'http://sub.example.com/test', {}), backend)
+ .subscribe();
+ const req = backend.expectOne('http://sub.example.com/test');
+ expect(req.request.headers.has('X-XSRF-TOKEN')).toBeTrue();
+ req.flush({});
+ });
+
+ it('does apply XSRF protection when request is protocol relative and same-origin', () => {
+ interceptor
+ .intercept(new HttpRequest('POST', '//sub.example.com/test', {}), backend)
+ .subscribe();
+ const req = backend.expectOne('//sub.example.com/test');
+ expect(req.request.headers.has('X-XSRF-TOKEN')).toBeTrue();
+ req.flush({});
+ });
+
it('does not overwrite existing header', () => {
interceptor
.intercept(