Skip to content
Closed
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
4 changes: 4 additions & 0 deletions src/server/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,10 @@ export class Frame extends SdkObject {
return this._parentFrame;
}

isMainFrame(): boolean {
return this._page.mainFrame() === this;
}

childFrames(): Frame[] {
return Array.from(this._childFrames);
}
Expand Down
2 changes: 1 addition & 1 deletion src/server/webkit/wkInterceptableRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const errorReasons: { [reason: string]: Protocol.Network.ResourceErrorType } = {
export class WKInterceptableRequest {
private readonly _session: WKSession;
readonly request: network.Request;
readonly _requestId: string;
_requestId: string;
_timestamp: number;
_wallTime: number;
readonly _route: WKRouteImpl | null;
Expand Down
106 changes: 103 additions & 3 deletions src/server/webkit/wkPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export class WKPage implements PageDelegate {
private _nextWindowOpenPopupFeatures?: string[];
private _recordingVideoFile: string | null = null;
private _screencastGeneration: number = 0;
private _currentMainFrameNavigation: WKNavigation | undefined;

constructor(browserContext: WKBrowserContext, pageProxySession: WKSession, opener: WKPage | null) {
this._pageProxySession = pageProxySession;
Expand Down Expand Up @@ -411,6 +412,8 @@ export class WKPage implements PageDelegate {
}

private _onFrameStoppedLoading(frameId: string) {
if (this._currentMainFrameNavigation?.shouldIgnoreFrameStoppedLoading(frameId))
return;
this._page._frameManager.frameStoppedLoading(frameId);
}

Expand Down Expand Up @@ -940,7 +943,26 @@ export class WKPage implements PageDelegate {
return result.handle;
}

_onRequestWillBeSent(session: WKSession, event: Protocol.Network.requestWillBeSentPayload) {
_onRequestWillBeSentProvisional(session: WKSession, event: Protocol.Network.requestWillBeSentPayload) {
if (!this._currentMainFrameNavigation?.checkIfSameNavigationInNewProcess(event)) {
this._onRequestWillBeSent(session, event);
return;
}
const oldId = this._currentMainFrameNavigation._originalRequestId;
const request = this._requestIdToRequest.get(oldId);
if (!request)
return;
const payload = this._requestIdToResponseReceivedPayloadEvent.get(oldId);
this._requestIdToRequest.delete(oldId);
this._requestIdToResponseReceivedPayloadEvent.delete(oldId);
const newId = event.requestId;
request._requestId = newId;
this._requestIdToRequest.set(newId, request);
if (payload)
this._requestIdToResponseReceivedPayloadEvent.set(newId, payload);
}

private _onRequestWillBeSent(session: WKSession, event: Protocol.Network.requestWillBeSentPayload) {
if (event.request.url.startsWith('data:'))
return;
let redirectedFrom: WKInterceptableRequest | null = null;
Expand All @@ -961,6 +983,8 @@ export class WKPage implements PageDelegate {
// TODO(einbinder) this will fail if we are an XHR document request
const isNavigationRequest = event.type === 'Document';
const documentId = isNavigationRequest ? event.loaderId : undefined;
if (isNavigationRequest && frame.isMainFrame())
this._currentMainFrameNavigation = new WKNavigation(this, event);
let route = null;
// We do not support intercepting redirects.
if (this._page._needsRequestInterception() && !redirectedFrom)
Expand All @@ -986,7 +1010,7 @@ export class WKPage implements PageDelegate {
session.sendMayFail('Network.interceptRequestWithError', { errorType: 'Cancellation', requestId: event.requestId });
return;
}
if (!request._route) {
if (!request._route || this._currentMainFrameNavigation?.shoudIgnoreRequestInterception(event)) {
// Intercepted, although we do not intend to allow interception.
// Just continue.
session.sendMayFail('Network.interceptWithRequest', { requestId: request._requestId });
Expand All @@ -1006,6 +1030,8 @@ export class WKPage implements PageDelegate {
}

_onResponseReceived(event: Protocol.Network.responseReceivedPayload) {
if (this._currentMainFrameNavigation?.checkIfDuplicateResponseEvent(event))
return;
const request = this._requestIdToRequest.get(event.requestId);
// FileUpload sends a response without a matching request.
if (!request)
Expand Down Expand Up @@ -1063,7 +1089,12 @@ export class WKPage implements PageDelegate {
this._page._frameManager.reportRequestFinished(request.request, response);
}

_onLoadingFailed(event: Protocol.Network.loadingFailedPayload) {
private _onLoadingFailed(event: Protocol.Network.loadingFailedPayload) {
if (!this._currentMainFrameNavigation?.shouldIgnoreLoadingFailedEvent(event))
this._onLoadingFailedShared(event);
}

_onLoadingFailedShared(event: Protocol.Network.loadingFailedPayload) {
const request = this._requestIdToRequest.get(event.requestId);
// For certain requestIds we never receive requestWillBeSent event.
// @see https://crbug.com/750469
Expand Down Expand Up @@ -1099,6 +1130,10 @@ export class WKPage implements PageDelegate {
async _clearPermissions() {
await this._pageProxySession.send('Emulation.resetPermissions', {});
}

hasProvisionalPage() {
return !!this._provisionalPage;
}
}

/**
Expand Down Expand Up @@ -1160,3 +1195,68 @@ function isLoadedSecurely(url: string, timing: network.ResourceTiming) {
return true;
} catch (_) {}
}


class WKNavigation {
private readonly _page: WKPage;
// loaderId is a navigation id which is unique within browser and persists during cross-process navigation.
private readonly _loaderId: string;
readonly _originalRequestId: Protocol.Network.RequestId;
private readonly _frameId: string;
private _responseReceived: boolean = false;
private _didCheckFirstRequestInProvisionalPage: boolean = false;
private _provisionalPageRequestId: string | undefined;

constructor(page: WKPage, event: Protocol.Network.requestWillBeSentPayload) {
this._page = page;
this._loaderId = event.loaderId;
this._originalRequestId = event.requestId;
this._frameId = event.frameId;
}

shouldIgnoreFrameStoppedLoading(frameId: string): boolean {
if (frameId !== this._frameId)
return false;
// Navigation in the original frame is canceled and actually continues in the
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am afraid this might not be always the case. For example, we navigate to cross-origin 204 page that creates a provisional page but never commits?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coincidentally that works because we generate loadingFinished event in WebKit ourselves when 204 response comes:

if (response.status() === 204) {
this._onLoadingFailed({
requestId: event.requestId,
errorText: 'Aborted: 204 No Content',
timestamp: event.timestamp
});
}
}

With network failures we will receive Playwright.provisionalLoadFailed and that will abort the navigation, so we are good in that case too. Added a test.

// provisional page, so we ignore failure events from the original page.
return this._page.hasProvisionalPage();
}

shouldIgnoreLoadingFailedEvent(event: Protocol.Network.loadingFailedPayload): boolean {
if (event.requestId !== this._originalRequestId)
return false;
if (!event.canceled)
return false;
// Navigation in the original frame is canceled and actually continues in the
// provisional page, so we ignore failure events from the original page.
return this._page.hasProvisionalPage();
}

checkIfSameNavigationInNewProcess(event: Protocol.Network.requestWillBeSentPayload): boolean {
if (this._didCheckFirstRequestInProvisionalPage)
return false;
this._didCheckFirstRequestInProvisionalPage = true;
if (event.loaderId !== this._loaderId)
return false;
this._provisionalPageRequestId = event.requestId;
return true;
}

checkIfDuplicateResponseEvent(event: Protocol.Network.responseReceivedPayload): boolean {
if (this._originalRequestId === event.requestId || this._provisionalPageRequestId === event.requestId) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we ignore responses from the old web process only? This ensures that we get correct response from the new web process, in case WebKit fixes more security holes in the old web process and send bogus response from there (if any).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the moment response notification comes from old process we don't know if it's gonna be a cross-process or same process navigation. In case of same-process navigation we have to dispatch messages from the old process. Unless we introduce a separate notification that the navigation turned into cross-process and buffer up some messages from old web process before the notification arrives we cannot safely ignore responses from the old web process .

// In case of cross-process navigation caused by Cross-Origin-Opener-Policy response header
// WebKit sends two responseReceived events from the old web process and one such event from the
// new provisonal web process.
if (this._responseReceived)
return true;
this._responseReceived = true;
}
return false;
}

shoudIgnoreRequestInterception(event: Protocol.Network.requestInterceptedPayload): boolean {
// It only makes sense to intercept request in the old process before it is sent to
// the server (in the new process the response is already received and will be replayed)
return this._provisionalPageRequestId === event.requestId;
}
}
4 changes: 2 additions & 2 deletions src/server/webkit/wkProvisionalPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@ export class WKProvisionalPage {
const wkPage = this._wkPage;

this._sessionListeners = [
eventsHelper.addEventListener(session, 'Network.requestWillBeSent', overrideFrameId(e => wkPage._onRequestWillBeSent(session, e))),
eventsHelper.addEventListener(session, 'Network.requestWillBeSent', overrideFrameId(e => wkPage._onRequestWillBeSentProvisional(session, e))),
eventsHelper.addEventListener(session, 'Network.requestIntercepted', overrideFrameId(e => wkPage._onRequestIntercepted(session, e))),
eventsHelper.addEventListener(session, 'Network.responseIntercepted', overrideFrameId(e => wkPage._onResponseIntercepted(session, e))),
eventsHelper.addEventListener(session, 'Network.responseReceived', overrideFrameId(e => wkPage._onResponseReceived(e))),
eventsHelper.addEventListener(session, 'Network.loadingFinished', overrideFrameId(e => wkPage._onLoadingFinished(e))),
eventsHelper.addEventListener(session, 'Network.loadingFailed', overrideFrameId(e => wkPage._onLoadingFailed(e))),
eventsHelper.addEventListener(session, 'Network.loadingFailed', overrideFrameId(e => wkPage._onLoadingFailedShared(e))),
];

this.initializationPromise = this._wkPage._initializeSession(session, true, ({ frameTree }) => this._handleFrameTree(frameTree));
Expand Down
32 changes: 32 additions & 0 deletions tests/download.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ it.describe('download event', () => {
res.write('foo');
res.uncork();
});
server.setRoute('/downloadWithCOOP', (req, res) => {
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', 'attachment');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.end(`Hello world`);
});
});

it('should report download when navigation turns into download', async ({ browser, server, browserName }) => {
Expand Down Expand Up @@ -67,6 +73,32 @@ it.describe('download event', () => {
await page.close();
});

it('should work with Cross-Origin-Opener-Policy', async ({ browser, server, browserName }) => {
const page = await browser.newPage({ acceptDownloads: true });
const [ download, responseOrError ] = await Promise.all([
page.waitForEvent('download'),
page.goto(server.PREFIX + '/downloadWithCOOP').catch(e => e)
]);
expect(download.page()).toBe(page);
expect(download.url()).toBe(`${server.PREFIX}/downloadWithCOOP`);
const path = await download.path();
expect(fs.existsSync(path)).toBeTruthy();
expect(fs.readFileSync(path).toString()).toBe('Hello world');
if (browserName === 'chromium') {
expect(responseOrError instanceof Error).toBeTruthy();
expect(responseOrError.message).toContain('net::ERR_ABORTED');
expect(page.url()).toBe('about:blank');
} else if (browserName === 'webkit') {
expect(responseOrError instanceof Error).toBeTruthy();
expect(responseOrError.message).toContain('Download is starting');
expect(page.url()).toBe('about:blank');
} else {
expect(responseOrError.status()).toBe(200);
expect(page.url()).toBe(server.PREFIX + '/download');
}
await page.close();
});

it('should report downloads with acceptDownloads: false', async ({ browser, server }) => {
const page = await browser.newPage();
await page.setContent(`<a href="${server.PREFIX}/downloadWithFilename">download</a>`);
Expand Down
77 changes: 74 additions & 3 deletions tests/page/page-goto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,85 @@ it('should work cross-process', async ({ page, server }) => {
expect(response.url()).toBe(url);
});

it('should work with Cross-Origin-Opener-Policy', async ({ page, server, browserName }) => {
it.fail(browserName === 'webkit', 'Regressed in https://trac.webkit.org/changeset/281516/webkit');
it('should work with cross-process that fails before committing', async ({ page, server, browserName }) => {
server.setRoute('/empty.html', (req, res) => {
req.socket.destroy();
});
const response1 = await page.goto(server.CROSS_PROCESS_PREFIX + '/title.html');
await response1.finished();
const error = await page.goto(server.EMPTY_PAGE).catch(e => e);
if (browserName === 'chromium')
expect(error.message).toContain('net::ERR_EMPTY_RESPONSE');
if (browserName === 'webkit')
expect(error.message).toContain('Message Corrupt');
if (browserName === 'firefox')
expect(error.message).toContain('NS_ERROR_NET_RESET');
});

it('should work with Cross-Origin-Opener-Policy', async ({ page, server }) => {
server.setRoute('/empty.html', (req, res) => {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.end();
});
await page.goto(server.EMPTY_PAGE);
const requests = new Set();
const events = [];
page.on('request', r => {
events.push('request');
requests.add(r);
});
page.on('requestfailed', r => {
events.push('requestfailed');
requests.add(r);
});
page.on('requestfinished', r => {
events.push('requestfinished');
requests.add(r);
});
page.on('response', r => {
events.push('response');
requests.add(r.request());
});
const response = await page.goto(server.EMPTY_PAGE);
expect(page.url()).toBe(server.EMPTY_PAGE);
await response.finished();
expect(events).toEqual(['request', 'response', 'requestfinished']);
expect(requests.size).toBe(1);
expect(response.request().failure()).toBeNull();
});

it('should work with Cross-Origin-Opener-Policy after redirect', async ({ page, server }) => {
server.setRedirect('/redirect', '/empty.html');
server.setRoute('/empty.html', (req, res) => {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.end();
});
const requests = new Set();
const events = [];
page.on('request', r => {
events.push('request');
requests.add(r);
});
page.on('requestfailed', r => {
events.push('requestfailed');
requests.add(r);
});
page.on('requestfinished', r => {
events.push('requestfinished');
requests.add(r);
});
page.on('response', r => {
events.push('response');
requests.add(r.request());
});
const response = await page.goto(server.PREFIX + '/redirect');
expect(page.url()).toBe(server.EMPTY_PAGE);
await response.finished();
expect(events).toEqual(['request', 'response', 'requestfinished', 'request', 'response', 'requestfinished']);
expect(requests.size).toBe(2);
expect(response.request().failure()).toBeNull();
const firstRequest = response.request().redirectedFrom();
expect(firstRequest).toBeTruthy();
expect(firstRequest.url()).toBe(server.PREFIX + '/redirect');
});

it('should capture iframe navigation request', async ({ page, server }) => {
Expand Down
52 changes: 52 additions & 0 deletions tests/page/page-request-continue.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,55 @@ it.describe('', () => {
expect(arr[i]).toBe(buffer[i]);
});
});

it('should work with Cross-Origin-Opener-Policy', async ({ page, server, browserName }) => {
let serverHeaders;
const serverRequests = [];
server.setRoute('/empty.html', (req, res) => {
serverRequests.push(req.url);
serverHeaders ??= req.headers;
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.end();
});

const intercepted = [];
await page.route('**/*', (route, req) => {
intercepted.push(req.url());
route.continue({
headers: {
foo: 'bar'
}
});
});
const requests = new Set();
const events = [];
page.on('request', r => {
events.push('request');
requests.add(r);
});
page.on('requestfailed', r => {
events.push('requestfailed');
requests.add(r);
});
page.on('requestfinished', r => {
events.push('requestfinished');
requests.add(r);
});
page.on('response', r => {
events.push('response');
requests.add(r.request());
});
const response = await page.goto(server.EMPTY_PAGE);
expect(intercepted).toEqual([server.EMPTY_PAGE]);
// There should be only one request to the server.
if (browserName === 'webkit')
expect(serverRequests).toEqual(['/empty.html', '/empty.html']);
else
expect(serverRequests).toEqual(['/empty.html']);
expect(serverHeaders['foo']).toBe('bar');
expect(page.url()).toBe(server.EMPTY_PAGE);
await response.finished();
expect(events).toEqual(['request', 'response', 'requestfinished']);
expect(requests.size).toBe(1);
expect(response.request().failure()).toBeNull();
});