Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: patch fetch and xhr inside cy.origin to get resourceType and credential Level #23822

Merged
Merged
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
544 changes: 539 additions & 5 deletions packages/driver/cypress/e2e/e2e/origin/patches.cy.ts

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions packages/driver/cypress/e2e/e2e/origin/snapshots.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('cy.origin - snapshots', () => {
})

cy.visit('/fixtures/primary-origin.html')
cy.get('a[data-cy="xhr-fetch-requests-onload"]').click()
})

// TODO: the xhr event is showing up twice in the log, which is wrong and causing flake. skipping until: https://github.com/cypress-io/cypress/issues/23840 is addressed.
Expand All @@ -47,7 +48,7 @@ describe('cy.origin - snapshots', () => {

// TODO: Since we have two events, one of them does not have a request snapshot

expect(snapshots[1].querySelector(`[data-cy="assertion-header"]`)).to.have.property('innerText').that.equals('Making XHR and Fetch Requests behind the scenes!')
expect(snapshots[1].querySelector(`[data-cy="assertion-header"]`)).to.have.property('innerText').that.equals('Making XHR and Fetch Requests behind the scenes if fireOnload is true!')
})
})

Expand All @@ -70,7 +71,7 @@ describe('cy.origin - snapshots', () => {
const snapshots = xhrLogFromSecondaryOrigin.snapshots.map((snapshot) => snapshot.body.get()[0])

snapshots.forEach((snapshot) => {
expect(snapshot.querySelector(`[data-cy="assertion-header"]`)).to.have.property('innerText').that.equals('Making XHR and Fetch Requests behind the scenes!')
expect(snapshot.querySelector(`[data-cy="assertion-header"]`)).to.have.property('innerText').that.equals('Making XHR and Fetch Requests behind the scenes if fireOnload is true!')
})
})
})
Expand Down
3 changes: 2 additions & 1 deletion packages/driver/cypress/fixtures/primary-origin.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
<li><a data-cy="files-form-link" href="http://www.foobar.com:3500/fixtures/files-form.html">http://www.foobar.com:3500/fixtures/files-form.html</a></li>
<li><a data-cy="errors-link" href="http://www.foobar.com:3500/fixtures/errors.html">http://www.foobar.com:3500/fixtures/errors.html</a></li>
<li><a data-cy="screenshots-link" href="http://www.foobar.com:3500/fixtures/screenshots.html">http://www.foobar.com:3500/fixtures/screenshots.html</a></li>
<li><a data-cy="xhr-fetch-requests" href="http://www.foobar.com:3500/fixtures/xhr-fetch-onload.html">http://www.foobar.com:3500/fixtures/xhr-fetch-onload.html</a></li>
<li><a data-cy="xhr-fetch-requests-onload" href="http://www.foobar.com:3500/fixtures/xhr-fetch-requests.html?fireOnload=true">http://www.foobar.com:3500/fixtures/xhr-fetch-requests.html onLoad</a></li>
<li><a data-cy="xhr-fetch-requests" href="http://www.foobar.com:3500/fixtures/xhr-fetch-requests.html">http://www.foobar.com:3500/fixtures/xhr-fetch-requests.html</a></li>
<li><a data-cy="integrity-link" href="http://www.foobar.com:3500/fixtures/scripts-with-integrity.html">http://www.foobar.com:3500/fixtures/scripts-with-integrity.html</a></li>
<li><a data-cy="cookie-login">Login with Social</a></li>
<li><a data-cy="cookie-login-https">Login with Social (https)</a></li>
Expand Down
18 changes: 0 additions & 18 deletions packages/driver/cypress/fixtures/xhr-fetch-onload.html

This file was deleted.

109 changes: 109 additions & 0 deletions packages/driver/cypress/fixtures/xhr-fetch-requests.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html>
<body>
<h1 data-cy="assertion-header">Making XHR and Fetch Requests behind the scenes if fireOnload is true!</h1>
<button data-cy="trigger-fetch" onclick="triggerFetch('/test-request')"> trigger fetch </button>
<button data-cy="trigger-fetch-with-request-object" onclick="triggerFetchWithRequestObject('/test-request')"> trigger fetch with Request Object</button>
<button data-cy="trigger-fetch-with-url-object" onclick="triggerFetchWithUrlObject('/test-request')" > trigger fetch with URL Object</button>
<button data-cy="trigger-fetch-omit" onclick="triggerFetch('/test-request', 'omit')"> trigger fetch w/ omit credentials </button>
<button data-cy="trigger-fetch-with-request-object-omit" onclick="triggerFetchWithRequestObject('/test-request', 'omit')"> trigger fetch with Request Object w/ omit credentials</button>
<button data-cy="trigger-fetch-with-url-object-omit" onclick="triggerFetchWithUrlObject('/test-request', 'omit')" > trigger fetch with URL Object w/ omit credentials</button>
<button data-cy="trigger-fetch-include" onclick="triggerFetch('/test-request', 'include')"> trigger fetch w/ include credentials </button>
<button data-cy="trigger-fetch-with-request-object-include" onclick="triggerFetchWithRequestObject('/test-request', 'include')"> trigger fetch with Request Object w/ include credentials</button>
<button data-cy="trigger-fetch-with-url-object-include" onclick="triggerFetchWithUrlObject('/test-request', 'include')" > trigger fetch with URL Object w/ include credentials</button>
<button data-cy="trigger-fetch-with-bad-options" onclick="triggerFetch(null)">trigger fetch with bad option</button>
<button data-cy="trigger-fetch-with-preflight" onclick="triggerFailingFetchPreflight('/test-request')">trigger fetch w/ preflight</button>
<button data-cy="trigger-xml-http-request" onclick="triggerXmlHttpRequest('/test-request')">trigger xmlHttpRequest</button>
<button data-cy="trigger-xml-http-request-with-credentials" onclick="triggerXmlHttpRequest('/test-request', true)">trigger xmlHttpRequest w/ credentials</button>
<button data-cy="trigger-xml-http-request-with-bad-options" onclick="triggerXmlHttpRequest(null)">trigger xmlHttpRequest w/ bad options</button>
<button data-cy="trigger-xml-http-request-with-preflight" onclick="triggerFailingXmlHttpRequestPreflight('/test-request')">trigger xmlHttpRequest w/ preflight</button>
<script>
function triggerFetch(requestOrUrlObjOrString, credentials){
let fetchReq
if(credentials){
fetchReq = fetch(requestOrUrlObjOrString, {
credentials
})
} else {
fetchReq = fetch(requestOrUrlObjOrString)
}
return fetchReq.then(function(response) {
// throw errors in our application to test when fetch fails
if (!response.ok) {
throw Error(response.status);
}
return response;
})
}

function triggerFetchWithRequestObject(urlString, credentials){
let req = new Request(urlString)
if(credentials){
// credentials must either match options passed into fetch or must exist on the Request object itself
req = new Request(urlString, {
credentials
})
}
return triggerFetch(req)
}

function triggerFetchWithUrlObject(urlString, credentials){
return triggerFetch(new URL(urlString, window.location.origin), credentials)
}

function triggerFailingFetchPreflight(relativeUrl){
let url = new URL(relativeUrl, 'http://app.foobar.com:3500').toString()
return fetch(url, {
headers: {
'foo': 'bar'
},
credentials: 'include'
}).catch(() => {
throw new Error('CORS ERROR');
}).then(() => {
throw new Error('request succeeded when it shouldn\'t have')
})
}

function triggerXmlHttpRequest(relativeUrl, withCredentials = false){
const url = new URL(relativeUrl, window.location.origin)
xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.withCredentials = withCredentials
xhr.send();
}

function triggerFailingXmlHttpRequestPreflight(relativeUrl){
// might need a cross origin req here
let url = new URL(relativeUrl, 'http://app.foobar.com:3500').toString()

let xhr = new XMLHttpRequest()

xhr.open('GET', url)
// adding headers to trigger a preflight request. @see https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests
xhr.setRequestHeader('foo', 'bar')
// since the plugin server sets the Access-Control-Allow-Origin to * (wildcard), this request will fail as a CORS errors. The
xhr.withCredentials = true

xhr.onerror = function () {
throw new Error('CORS ERROR')
}

xhr.send()
}

function fireXHRAndFetchRequests() {
if(window.location.search.includes('fireOnload=true')){
xhr = new XMLHttpRequest();
xhr.open("GET", "http://localhost:3500/foo.bar.baz.json");
xhr.responseType = "json";
xhr.send();

fetch("http://localhost:3500/foo.bar.baz.json")
}
}

fireXHRAndFetchRequests()
</script>
</body>
</html>
11 changes: 11 additions & 0 deletions packages/driver/src/cross-origin/cypress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { handleTestEvents } from './events/test'
import { handleMiscEvents } from './events/misc'
import { handleUnsupportedAPIs } from './unsupported_apis'
import { patchFormElementSubmit } from './patches/submit'
import { patchFetch } from './patches/fetch'
import { patchXmlHttpRequest } from './patches/xmlHttpRequest'
import $Mocha from '../cypress/mocha'
import * as cors from '@packages/network/lib/cors'

Expand Down Expand Up @@ -173,6 +175,15 @@ const attachToWindow = (autWindow: Window) => {

cy.overrides.wrapNativeMethods(autWindow)

// place after override incase fetch is polyfilled in the AUT injection
// this can be in the beforeLoad code as we only want to patch fetch/xmlHttpRequest
// when the cy.origin block is active to track credential use
patchFetch(Cypress, autWindow)
patchXmlHttpRequest(Cypress, autWindow)
// also patch it in the spec bridge as well
patchFetch(Cypress, window)
patchXmlHttpRequest(Cypress, window)

// TODO: DRY this up with the mostly-the-same code in src/cypress/cy.js
// https://github.com/cypress-io/cypress/issues/20972
bindToListeners(autWindow, {
Expand Down
51 changes: 51 additions & 0 deletions packages/driver/src/cross-origin/patches/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { captureFullRequestUrl } from './utils'

export const patchFetch = (Cypress: Cypress.Cypress, window) => {
// if fetch is available in the browser, or is polyfilled by whatwg fetch
// intercept method calls and add cypress headers to determine cookie applications in the proxy
// for simulated top. @see https://github.github.io/fetch/ for default options
if (!Cypress.config('experimentalSessionAndOrigin') || !window.fetch) {
return
}

const originalFetch = window.fetch

window.fetch = function (...args) {
try {
let url: string | undefined = undefined
let credentials: string | undefined = undefined

const resource = args[0]

// @see https://developer.mozilla.org/en-US/docs/Web/API/fetch#parameters for fetch resource options. We will only support Request, URL, and strings
if (resource instanceof window.Request) {
({ url, credentials } = resource)
} else if (resource instanceof window.URL) {
// should be a no-op for URL
url = resource.toString()

;({ credentials } = args[1] || {})
} else if (Cypress._.isString(resource)) {
url = captureFullRequestUrl(resource, window)

;({ credentials } = args[1] || {})
}

credentials = credentials || 'same-origin'
// if the option is specified, communicate it to the the server to the proxy can make the request aware if it needs to potentially apply cross origin cookies
// if the option isn't set, we can imply the default as we know the resource type in the proxy
if (url) {
// @ts-expect-error
Cypress.backend('request:sent:with:credentials', {
// TODO: might need to go off more information here or at least make collisions less likely
url,
resourceType: 'fetch',
credentialStatus: credentials,
})
}
} finally {
// if our internal logic errors for whatever reason, do NOT block the end user and continue the request
return originalFetch.apply(this, args)
}
}
}
17 changes: 17 additions & 0 deletions packages/driver/src/cross-origin/patches/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const captureFullRequestUrl = (relativeOrAbsoluteUrlString: string, window: Window) => {
// need to pass the window here by reference to generate the correct absolute URL if needed. Spec Bridge does NOT contain sub domain
let url

try {
url = new URL(relativeOrAbsoluteUrlString).toString()
} catch (err1) {
try {
// likely a relative path, construct the full url
url = new URL(relativeOrAbsoluteUrlString, window.location.origin).toString()
} catch (err2) {
return undefined
}
}

return url
}
42 changes: 42 additions & 0 deletions packages/driver/src/cross-origin/patches/xmlHttpRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { captureFullRequestUrl } from './utils'

export const patchXmlHttpRequest = (Cypress: Cypress.Cypress, window: Window) => {
// intercept method calls and add cypress headers to determine cookie applications in the proxy
// for simulated top

if (!Cypress.config('experimentalSessionAndOrigin')) {
return
}

const originalXmlHttpRequestOpen = window.XMLHttpRequest.prototype.open
const originalXmlHttpRequestSend = window.XMLHttpRequest.prototype.send

window.XMLHttpRequest.prototype.open = function (...args) {
try {
// since the send method does NOT have access to the arguments passed into open or have the request information,
// we need to store a reference here to what we need in the send method
this._url = captureFullRequestUrl(args[1], window)
} finally {
return originalXmlHttpRequestOpen.apply(this, args as any)
}
}

window.XMLHttpRequest.prototype.send = function (...args) {
try {
// if the option is specified, communicate it to the the server to the proxy can make the request aware if it needs to potentially apply cross origin cookies
// if the option isn't set, we can imply the default as we know the resource type in the proxy
if (this._url) {
// @ts-expect-error
Cypress.backend('request:sent:with:credentials', {
// TODO: might need to go off more information here or at least make collisions less likely
url: this._url,
resourceType: 'xhr',
credentialStatus: this.withCredentials,
})
}
} finally {
// if our internal logic errors for whatever reason, do NOT block the end user and continue the request
return originalXmlHttpRequestSend.apply(this, args)
}
}
}
3 changes: 3 additions & 0 deletions packages/server/lib/socket-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,9 @@ export class SocketBase {
}
case 'cross:origin:automation:cookies:received':
return this.localBus.emit('cross:origin:automation:cookies:received')
case 'request:sent:with:credentials':
// NOTE: this is currently a no-op until the server logic is implemented
return this.localBus.emit('request:sent:with:credentials', args[0])
Comment on lines +487 to +489
Copy link
Member

Choose a reason for hiding this comment

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

@AtofStryker Given @mjhenkes' changes will be landing soon to remove the localBut events for cross-origin, do you expect this will event will continue to emit as a a localBut event once that merges?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes everything should still emit as normal since the localBus is used heavily for automation tasks and we still have the setup in the server for the cross:origin:automation:cookies events

default:
throw new Error(`You requested a backend event we cannot handle: ${eventName}`)
}
Expand Down