Skip to content

Commit 909b21a

Browse files
martinsikthePunderWoman
authored andcommitted
refactor(http): change <script>'s ownerDocument in jsonp teardown (#36807)
handler Cancel pending json handler by adopting its <script> element into another document (https://html.spec.whatwg.org/multipage/scripting.html#execute-the-script-block) This way the browser will prevent the script from being parsed and executed. Fixes #34818 PR Close #36807
1 parent 7671a1e commit 909b21a

3 files changed

Lines changed: 57 additions & 41 deletions

File tree

packages/common/http/src/jsonp.ts

Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ import {HttpErrorResponse, HttpEvent, HttpEventType, HttpResponse, HttpStatusCod
2121
// is shared among all applications on the page.
2222
let nextRequestId: number = 0;
2323

24+
/**
25+
* When a pending <script> is unsubscribed we'll move it to this document, so it won't be
26+
* executed.
27+
*/
28+
let foreignDocument: Document|undefined;
29+
2430
// Error text given when a JSONP script is injected, but doesn't invoke the callback
2531
// passed in its URL.
2632
export const JSONP_ERR_NO_CALLBACK = 'JSONP injected script did not invoke callback.';
@@ -101,22 +107,13 @@ export class JsonpClientBackend implements HttpBackend {
101107
// Whether the response callback has been called.
102108
let finished: boolean = false;
103109

104-
// Whether the request has been cancelled (and thus any other callbacks)
105-
// should be ignored.
106-
let cancelled: boolean = false;
107-
108110
// Set the response callback in this.callbackMap (which will be the window
109111
// object in the browser. The script being loaded via the <script> tag will
110112
// eventually call this callback.
111113
this.callbackMap[callback] = (data?: any) => {
112114
// Data has been received from the JSONP script. Firstly, delete this callback.
113115
delete this.callbackMap[callback];
114116

115-
// Next, make sure the request wasn't cancelled in the meantime.
116-
if (cancelled) {
117-
return;
118-
}
119-
120117
// Set state to indicate data was received.
121118
body = data;
122119
finished = true;
@@ -125,29 +122,22 @@ export class JsonpClientBackend implements HttpBackend {
125122
// cleanup() is a utility closure that removes the <script> from the page and
126123
// the response callback from the window. This logic is used in both the
127124
// success, error, and cancellation paths, so it's extracted out for convenience.
128-
const cleanup = (removeCallback = true) => {
125+
const cleanup = () => {
129126
// Remove the <script> tag if it's still on the page.
130127
if (node.parentNode) {
131128
node.parentNode.removeChild(node);
132129
}
133130

134-
if (removeCallback) {
135-
// Remove the response callback from the callbackMap (window object in the
136-
// browser).
137-
delete this.callbackMap[callback];
138-
}
131+
// Remove the response callback from the callbackMap (window object in the
132+
// browser).
133+
delete this.callbackMap[callback];
139134
};
140135

141136
// onLoad() is the success callback which runs after the response callback
142137
// if the JSONP script loads successfully. The event itself is unimportant.
143138
// If something went wrong, onLoad() may run without the response callback
144139
// having been invoked.
145140
const onLoad = (event: Event) => {
146-
// Do nothing if the request has been cancelled.
147-
if (cancelled) {
148-
return;
149-
}
150-
151141
// We wrap it in an extra Promise, to ensure the microtask
152142
// is scheduled after the loaded endpoint has executed any potential microtask itself,
153143
// which is not guaranteed in Internet Explorer and EdgeHTML. See issue #39496
@@ -186,10 +176,6 @@ export class JsonpClientBackend implements HttpBackend {
186176
// a Javascript error. It emits the error via the Observable error channel as
187177
// a HttpErrorResponse.
188178
const onError: any = (error: Error) => {
189-
// If the request was already cancelled, no need to emit anything.
190-
if (cancelled) {
191-
return;
192-
}
193179
cleanup();
194180

195181
// Wrap the error in a HttpErrorResponse.
@@ -212,20 +198,25 @@ export class JsonpClientBackend implements HttpBackend {
212198

213199
// Cancellation handler.
214200
return () => {
215-
// Track the cancellation so event listeners won't do anything even if already scheduled.
216-
cancelled = true;
217-
218-
// Remove the event listeners so they won't run if the events later fire.
219-
node.removeEventListener('load', onLoad);
220-
node.removeEventListener('error', onError);
201+
if (!finished) {
202+
this.removeListeners(node);
203+
}
221204

222205
// And finally, clean up the page.
223-
// Unsubscription won't remove the callback handler because there's no way to stop
224-
// loading <script> once it has been added to DOM.
225-
cleanup(false);
206+
cleanup();
226207
};
227208
});
228209
}
210+
211+
private removeListeners(script: HTMLScriptElement): void {
212+
// Issue #34818
213+
// Changing <script>'s ownerDocument will prevent it from execution.
214+
// https://html.spec.whatwg.org/multipage/scripting.html#execute-the-script-block
215+
if (!foreignDocument) {
216+
foreignDocument = (this.document.implementation as DOMImplementation).createHTMLDocument();
217+
}
218+
foreignDocument.adoptNode(script);
219+
}
229220
}
230221

231222
/**

packages/common/http/test/jsonp_mock.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
export class MockScriptElement {
10-
constructor() {}
10+
constructor(public ownerDocument: MockDocument) {}
1111

1212
listeners: {
1313
load?: (event: Event) => void,
@@ -28,8 +28,12 @@ export class MockDocument {
2828
mock!: MockScriptElement|null;
2929
readonly body: any = this;
3030

31+
implementation = {
32+
createHTMLDocument: () => new MockDocument(),
33+
};
34+
3135
createElement(tag: 'script'): HTMLScriptElement {
32-
return new MockScriptElement() as any as HTMLScriptElement;
36+
return new MockScriptElement(this) as any as HTMLScriptElement;
3337
}
3438

3539
appendChild(node: any): void {
@@ -42,11 +46,23 @@ export class MockDocument {
4246
}
4347
}
4448

49+
adoptNode(node: any) {
50+
node.ownerDocument = this;
51+
}
52+
4553
mockLoad(): void {
46-
this.mock!.listeners.load!(null as any);
54+
// Mimic behavior described by
55+
// https://html.spec.whatwg.org/multipage/scripting.html#execute-the-script-block
56+
if (this.mock!.ownerDocument === this) {
57+
this.mock!.listeners.load!(null as any);
58+
}
4759
}
4860

4961
mockError(err: Error) {
50-
this.mock!.listeners.error!(err);
62+
// Mimic behavior described by
63+
// https://html.spec.whatwg.org/multipage/scripting.html#execute-the-script-block
64+
if (this.mock!.ownerDocument === this) {
65+
this.mock!.listeners.error!(err);
66+
}
5167
}
5268
}

packages/common/http/test/jsonp_spec.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const SAMPLE_REQ = new HttpRequest<never>('JSONP', '/test');
2424

2525
{
2626
describe('JsonpClientBackend', () => {
27-
let home = {};
27+
let home: any;
2828
let document: MockDocument;
2929
let backend: JsonpClientBackend;
3030
beforeEach(() => {
@@ -62,10 +62,19 @@ const SAMPLE_REQ = new HttpRequest<never>('JSONP', '/test');
6262
});
6363
document.mockError(error);
6464
});
65-
it('allows the callback to be invoked when the request is cancelled', () => {
66-
backend.handle(SAMPLE_REQ).subscribe().unsubscribe();
67-
runOnlyCallback(home, {data: 'This is a test'});
65+
it('prevents the script from executing when the request is cancelled', () => {
66+
const sub = backend.handle(SAMPLE_REQ).subscribe();
67+
expect(Object.keys(home).length).toBe(1);
68+
const keys = Object.keys(home);
69+
const spy = jasmine.createSpy('spy', home[keys[0]]);
70+
71+
sub.unsubscribe();
72+
document.mockLoad();
6873
expect(Object.keys(home).length).toBe(0);
74+
expect(spy).not.toHaveBeenCalled();
75+
// The script element should have been transferred to a different document to prevent it from
76+
// executing.
77+
expect(document.mock!.ownerDocument).not.toEqual(document);
6978
});
7079
describe('throws an error', () => {
7180
it('when request method is not JSONP',

0 commit comments

Comments
 (0)