From fa940e1f4de75c33ccca50357d941be53a5a0950 Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:06:54 -0500 Subject: [PATCH] fix(http): Rejects non-HTTP(S) URLs in JSONP requests Prevents JSONP requests from using URLs with unsupported protocols for improved security. Fixes angular#68832 --- goldens/public-api/common/http/errors.api.md | 2 + .../playground/src/jsonp/app/jsonp_comp.ts | 4 +- packages/common/http/src/errors.ts | 1 + packages/common/http/src/jsonp.ts | 12 ++++++ packages/common/http/test/jsonp_spec.ts | 40 ++++++++++++++++++- 5 files changed, 57 insertions(+), 2 deletions(-) diff --git a/goldens/public-api/common/http/errors.api.md b/goldens/public-api/common/http/errors.api.md index be3a6596528..51cd0db2452 100644 --- a/goldens/public-api/common/http/errors.api.md +++ b/goldens/public-api/common/http/errors.api.md @@ -25,6 +25,8 @@ export const enum RuntimeErrorCode { // (undocumented) JSONP_HEADERS_NOT_SUPPORTED = 2812, // (undocumented) + JSONP_UNSAFE_URL = 2826, + // (undocumented) JSONP_WRONG_METHOD = 2810, // (undocumented) JSONP_WRONG_RESPONSE_TYPE = 2811, diff --git a/modules/playground/src/jsonp/app/jsonp_comp.ts b/modules/playground/src/jsonp/app/jsonp_comp.ts index 47451da2654..3f54f7511f5 100644 --- a/modules/playground/src/jsonp/app/jsonp_comp.ts +++ b/modules/playground/src/jsonp/app/jsonp_comp.ts @@ -27,7 +27,9 @@ export class JsonpCmp { people: Person[] = []; constructor(http: HttpClient) { - http.jsonp('./people.json', 'callback').subscribe((res: unknown) => { + const peopleUrl = new URL('./people.json', window.location.href).toString(); + + http.jsonp(peopleUrl, 'callback').subscribe((res: unknown) => { this.people = res as Person[]; }); } diff --git a/packages/common/http/src/errors.ts b/packages/common/http/src/errors.ts index 656531dd1d8..bc40c12e07d 100644 --- a/packages/common/http/src/errors.ts +++ b/packages/common/http/src/errors.ts @@ -34,4 +34,5 @@ export const enum RuntimeErrorCode { INTEGRITY_NOT_SUPPORTED_WITH_XHR = 2820, REFERRER_NOT_SUPPORTED_WITH_XHR = 2821, INVALID_TIMEOUT_VALUE = 2822, + JSONP_UNSAFE_URL = 2826, } diff --git a/packages/common/http/src/jsonp.ts b/packages/common/http/src/jsonp.ts index e41bd5401b3..68bc63ab0b3 100644 --- a/packages/common/http/src/jsonp.ts +++ b/packages/common/http/src/jsonp.ts @@ -54,6 +54,10 @@ export const JSONP_ERR_WRONG_RESPONSE_TYPE = 'JSONP requests must use Json respo // headers set export const JSONP_ERR_HEADERS_NOT_SUPPORTED = 'JSONP requests do not support headers.'; +// Error text given when a JSONP request URL is not absolute HTTP(S). +export const JSONP_ERR_UNSAFE_URL = + 'JSONP requests only support absolute URLs with HTTP(S) protocols.'; + /** * DI token/abstract type representing a map of JSONP callbacks. * @@ -137,6 +141,10 @@ export class JsonpClientBackend implements HttpBackend { ); } + if (!this.isAllowedJsonpUrl(req.urlWithParams)) { + throw new RuntimeError(RuntimeErrorCode.JSONP_UNSAFE_URL, ngDevMode && JSONP_ERR_UNSAFE_URL); + } + // Everything else happens inside the Observable boundary. return new Observable>((observer: Observer>) => { // The first step to make a request is to generate the callback name, and replace the @@ -274,6 +282,10 @@ export class JsonpClientBackend implements HttpBackend { foreignDocument.adoptNode(script); } + + private isAllowedJsonpUrl(url: string): boolean { + return /^https?:\/\//i.test(url); + } } /** diff --git a/packages/common/http/test/jsonp_spec.ts b/packages/common/http/test/jsonp_spec.ts index d323627e874..e2fa6cdcfd0 100644 --- a/packages/common/http/test/jsonp_spec.ts +++ b/packages/common/http/test/jsonp_spec.ts @@ -10,6 +10,7 @@ import {HttpHeaders} from '../src/headers'; import { JSONP_ERR_HEADERS_NOT_SUPPORTED, JSONP_ERR_NO_CALLBACK, + JSONP_ERR_UNSAFE_URL, JSONP_ERR_WRONG_METHOD, JSONP_ERR_WRONG_RESPONSE_TYPE, JsonpClientBackend, @@ -21,7 +22,7 @@ import {toArray} from 'rxjs/operators'; import {MockDocument} from './jsonp_mock'; describe('JsonpClientBackend', () => { - const SAMPLE_REQ = new HttpRequest('JSONP', '/test'); + const SAMPLE_REQ = new HttpRequest('JSONP', 'https://example.com/test'); let home: any; let document: MockDocument; let backend: JsonpClientBackend; @@ -87,6 +88,43 @@ describe('JsonpClientBackend', () => { // executing. expect(document.mock!.ownerDocument).not.toEqual(document); }); + describe('URL protocols', () => { + it('allows absolute HTTP(S) URLs', () => { + const urls = [ + 'http://example.com/test', + 'https://example.com/test', + 'HTTP://example.com/test', + ]; + + for (const url of urls) { + const subscription = backend.handle(SAMPLE_REQ.clone({url})).subscribe(); + + subscription.unsubscribe(); + } + }); + + it('rejects URLs without absolute HTTP(S) protocols before creating a script element', () => { + const urls = [ + '//example.com/test', + '/test', + 'test', + 'data:text/javascript,alert(1)', + 'blob:https://example.com/jsonp', + 'javascript:alert(1)', + 'file:///tmp/jsonp.js', + 'filesystem:https://example.com/temporary/jsonp.js', + 'ftp://example.com/jsonp.js', + 'custom-scheme://example.com/jsonp.js', + ]; + + for (const url of urls) { + expect(() => backend.handle(SAMPLE_REQ.clone({url}))).toThrowError( + `NG02826: ${JSONP_ERR_UNSAFE_URL}`, + ); + expect(document.mock).toBeUndefined(); + } + }); + }); describe('throws an error', () => { it('when request method is not JSONP', () => expect(() => backend.handle(SAMPLE_REQ.clone({method: 'GET'}))).toThrowError(