Skip to content

Commit 5ff1504

Browse files
authored
feat: add experimentalModifyObstructiveThirdPartyCode flag for regex rewriter (#22568)
1 parent f0d3a48 commit 5ff1504

33 files changed

+1125
-99
lines changed

cli/types/cypress.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2849,6 +2849,15 @@ declare namespace Cypress {
28492849
* @default false
28502850
*/
28512851
experimentalSessionAndOrigin: boolean
2852+
/**
2853+
* Whether Cypress will search for and replace obstructive code in third party .js or .html files.
2854+
* NOTE: Setting this flag to true removes Subresource Integrity (SRI).
2855+
* Please see https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity.
2856+
* This option has no impact on experimentalSourceRewriting and is only used with the
2857+
* non-experimental source rewriter.
2858+
* @see https://on.cypress.io/configuration#experimentalModifyObstructiveThirdPartyCode
2859+
*/
2860+
experimentalModifyObstructiveThirdPartyCode: boolean
28522861
/**
28532862
* Generate and save commands directly to your test suite by interacting with your app as an end user would.
28542863
* @default false

packages/config/__snapshots__/index.spec.ts.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ exports['config/src/index .getDefaultValues returns list of public config keys 1
3737
"experimentalFetchPolyfill": false,
3838
"experimentalInteractiveRunEvents": false,
3939
"experimentalSessionAndOrigin": false,
40+
"experimentalModifyObstructiveThirdPartyCode": false,
4041
"experimentalSourceRewriting": false,
4142
"fileServerFolder": "",
4243
"fixturesFolder": "cypress/fixtures",
@@ -115,6 +116,7 @@ exports['config/src/index .getDefaultValues returns list of public config keys f
115116
"experimentalFetchPolyfill": false,
116117
"experimentalInteractiveRunEvents": false,
117118
"experimentalSessionAndOrigin": false,
119+
"experimentalModifyObstructiveThirdPartyCode": false,
118120
"experimentalSourceRewriting": false,
119121
"fileServerFolder": "",
120122
"fixturesFolder": "cypress/fixtures",
@@ -190,6 +192,7 @@ exports['config/src/index .getPublicConfigKeys returns list of public config key
190192
"experimentalFetchPolyfill",
191193
"experimentalInteractiveRunEvents",
192194
"experimentalSessionAndOrigin",
195+
"experimentalModifyObstructiveThirdPartyCode",
193196
"experimentalSourceRewriting",
194197
"fileServerFolder",
195198
"fixturesFolder",

packages/config/src/options.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,13 @@ const resolvedOptions: Array<ResolvedConfigOption> = [
211211
validation: validate.isBoolean,
212212
isExperimental: true,
213213
canUpdateDuringTestTime: false,
214+
}, {
215+
name: 'experimentalModifyObstructiveThirdPartyCode',
216+
defaultValue: false,
217+
validation: validate.isBoolean,
218+
isExperimental: true,
219+
canUpdateDuringTestTime: false,
220+
requireRestartOnChange: 'server',
214221
}, {
215222
name: 'experimentalSourceRewriting',
216223
defaultValue: false,
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import CryptoJS from 'crypto-js'
2+
import type { TemplateExecutor } from 'lodash'
3+
4+
// NOTE: in order to run these tests, the following config flags need to be set
5+
// experimentalSessionAndOrigin=true
6+
// experimentalModifyObstructiveThirdPartyCode=true
7+
describe('Integrity Preservation', () => {
8+
// Add common SRI hashes used when setting script/link integrity.
9+
// These are the ones supported by SRI (see https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity#using_subresource_integrity)
10+
// For our tests, we will use CryptoJS to calculate these hashes as they can regenerate the integrity without us having to do it manually every
11+
// single time the file changes. But if needed, this can be generated manually in the console by running simply run:
12+
// cat integrity.js|css | openssl dgst -sha384 -binary | openssl base64 -A
13+
// the outputted hash is appended to the algorithm name, all lowercase with a trailing dash. For example:
14+
// sha256-MGkilwijzWAi/LutxKC+CWhsXXc6t1tXTMqY1zakP8c=
15+
// See https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity on SRI integrity.
16+
17+
const availableDigests = ['SHA256', 'SHA384', 'SHA512']
18+
const integrityJSDigests: {[key: string]: string} = {}
19+
const integrityCSSDigests: {[key: string]: string} = {}
20+
let templateExecutor: TemplateExecutor
21+
22+
before(() => {
23+
// Before running our tests, we need to build out digests to inject into our HTML ejs template
24+
// so we can set the integrity tag appropriately for the digest.
25+
26+
// This requires building digests for the integrity JS file that the regex-rewriter will rewrite.
27+
cy.readFile('cypress/fixtures/integrity.js').then((integrityJS) => {
28+
availableDigests.forEach((algo) => {
29+
const hash = CryptoJS[algo](integrityJS)
30+
const stringifiedBase64 = hash.toString(CryptoJS.enc.Base64)
31+
32+
integrityJSDigests[algo] = stringifiedBase64
33+
})
34+
})
35+
36+
// And building digests for the integrity CSS file that SHOULDN'T be impacted, but important to test against.
37+
cy.readFile('cypress/fixtures/integrity.css').then((integrityCSS) => {
38+
availableDigests.forEach((algo) => {
39+
const hash = CryptoJS[algo](integrityCSS)
40+
const stringifiedBase64 = hash.toString(CryptoJS.enc.Base64)
41+
42+
integrityCSSDigests[algo] = stringifiedBase64
43+
})
44+
})
45+
46+
cy.fixture('scripts-with-integrity').then((integrityTemplate) => {
47+
templateExecutor = Cypress._.template(integrityTemplate, { variable: 'data' })
48+
})
49+
})
50+
51+
describe('<script> tags', () => {
52+
availableDigests.forEach((algo) => {
53+
it(`preserves integrity with static <script> in HTML with ${algo} integrity.`, () => {
54+
cy.then(() => {
55+
const compiledTemplate = templateExecutor({
56+
staticScriptInjection: true,
57+
integrityValue: `${algo.toLowerCase()}-${integrityJSDigests[algo]}`,
58+
})
59+
60+
cy.intercept('http://www.foobar.com:3500/fixtures/scripts-with-integrity.html', compiledTemplate)
61+
})
62+
63+
cy.visit('fixtures/primary-origin.html')
64+
cy.get('[data-cy="integrity-link"]').click()
65+
cy.origin('http://foobar.com:3500', () => {
66+
// The added script, if integrity matches, should execute and
67+
// add a <p> element with 'integrity script loaded' as the text
68+
cy.get('#integrity', {
69+
timeout: 1000,
70+
}).should('contain', 'integrity script loaded')
71+
72+
cy.get('#static-set-integrity-script').should('have.attr', 'cypress-stripped-integrity')
73+
})
74+
})
75+
76+
it(`preserves integrity with dynamically added <script> in HTML with ${algo} integrity.`, () => {
77+
cy.then(() => {
78+
const compiledTemplate = templateExecutor({
79+
dynamicScriptInjection: true,
80+
integrityValue: `${algo.toLowerCase()}-${integrityJSDigests[algo]}`,
81+
})
82+
83+
cy.intercept('http://www.foobar.com:3500/fixtures/scripts-with-integrity.html', compiledTemplate)
84+
})
85+
86+
cy.visit('fixtures/primary-origin.html')
87+
cy.get('[data-cy="integrity-link"]').click()
88+
cy.origin('http://foobar.com:3500', () => {
89+
// The added script, if integrity matches, should execute and
90+
// add a <p> element with 'integrity script loaded' as the text
91+
cy.get('#integrity', {
92+
timeout: 1000,
93+
}).should('contain', 'integrity script loaded')
94+
95+
cy.get('#dynamic-set-integrity-script').should('have.attr', 'cypress-stripped-integrity')
96+
})
97+
})
98+
})
99+
})
100+
101+
describe('<link> tags', () => {
102+
availableDigests.forEach((algo) => {
103+
it(`preserves integrity with static <link> in HTML with ${algo} integrity.`, () => {
104+
cy.then(() => {
105+
const compiledTemplate = templateExecutor({
106+
staticLinkInjection: true,
107+
integrityValue: `${algo.toLowerCase()}-${integrityCSSDigests[algo]}`,
108+
})
109+
110+
cy.intercept('http://www.foobar.com:3500/fixtures/scripts-with-integrity.html', compiledTemplate)
111+
})
112+
113+
cy.visit('fixtures/primary-origin.html')
114+
cy.get('[data-cy="integrity-link"]').click()
115+
cy.origin('http://foobar.com:3500', () => {
116+
cy.get('[data-cy="integrity-header"]', {
117+
timeout: 1000,
118+
}).then((integrityHeader) => {
119+
// The added link, if integrity matches, should execute and
120+
// add a color 'red' to the data-cy="integrity-header" element
121+
expect(window.getComputedStyle(integrityHeader[0]).getPropertyValue('color')).to.equal('rgb(255, 0, 0)')
122+
})
123+
124+
cy.get('#static-set-integrity-link').should('have.attr', 'cypress-stripped-integrity')
125+
})
126+
})
127+
128+
it(`preserves integrity with dynamically added <link> in HTML with ${algo} integrity.`, () => {
129+
cy.then(() => {
130+
const compiledTemplate = templateExecutor({
131+
dynamicLinkInjection: true,
132+
integrityValue: `${algo.toLowerCase()}-${integrityCSSDigests[algo]}`,
133+
})
134+
135+
cy.intercept('http://www.foobar.com:3500/fixtures/scripts-with-integrity.html', compiledTemplate)
136+
})
137+
138+
cy.visit('fixtures/primary-origin.html')
139+
cy.get('[data-cy="integrity-link"]').click()
140+
cy.origin('http://foobar.com:3500', () => {
141+
cy.get('[data-cy="integrity-header"]', {
142+
timeout: 1000,
143+
}).then((integrityHeader) => {
144+
// The added link, if integrity matches, should execute and
145+
// add a color 'red' to the data-cy="integrity-header" element
146+
expect(window.getComputedStyle(integrityHeader[0]).getPropertyValue('color')).to.equal('rgb(255, 0, 0)')
147+
})
148+
149+
cy.get('#dynamic-set-integrity-link').should('have.attr', 'cypress-stripped-integrity')
150+
})
151+
})
152+
})
153+
})
154+
})
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
describe('src/cross-origin/patches', () => {
2+
beforeEach(() => {
3+
cy.visit('/fixtures/primary-origin.html')
4+
cy.get('a[data-cy="cross-origin-secondary-link"]').click()
5+
})
6+
7+
context('submit', () => {
8+
it('correctly submits a form when the target is _top for HTMLFormElement', () => {
9+
cy.origin('http://www.foobar.com:3500', () => {
10+
cy.get('form').then(($form) => {
11+
expect($form.attr('target')).to.equal('_top')
12+
$form[0].submit()
13+
})
14+
15+
cy.contains('Some generic content')
16+
})
17+
})
18+
})
19+
20+
context('setAttribute', () => {
21+
it('renames integrity to cypress-stripped-integrity for HTMLScriptElement', () => {
22+
cy.origin('http://www.foobar.com:3500', () => {
23+
cy.window().then((win: Window) => {
24+
const script = win.document.createElement('script')
25+
26+
script.setAttribute('integrity', 'sha-123')
27+
expect(script.getAttribute('integrity')).to.be.null
28+
expect(script.getAttribute('cypress-stripped-integrity')).to.equal('sha-123')
29+
})
30+
})
31+
})
32+
33+
it('renames integrity to cypress-stripped-integrity for HTMLLinkElement', () => {
34+
cy.origin('http://www.foobar.com:3500', () => {
35+
cy.window().then((win: Window) => {
36+
const script = win.document.createElement('link')
37+
38+
script.setAttribute('integrity', 'sha-123')
39+
expect(script.getAttribute('integrity')).to.be.null
40+
expect(script.getAttribute('cypress-stripped-integrity')).to.equal('sha-123')
41+
})
42+
})
43+
})
44+
45+
it('doesn\'t rename integrity for other elements', () => {
46+
cy.origin('http://www.foobar.com:3500', () => {
47+
cy.get('button[data-cy="alert"]').then(($button) => {
48+
$button.attr('integrity', 'sha-123')
49+
expect($button.attr('integrity')).to.equal('sha-123')
50+
expect($button.attr('cypress-stripped-integrity')).to.be.undefined
51+
})
52+
})
53+
})
54+
})
55+
})
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[data-cy="integrity-header"] {
2+
color: rgb(255, 0, 0);
3+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
var paragraph = document.createElement('p')
2+
paragraph.id = 'integrity'
3+
paragraph.textContent = 'integrity script loaded'
4+
document.querySelector('body').appendChild(paragraph)

packages/driver/cypress/fixtures/primary-origin.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<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>
1414
<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>
1515
<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>
16+
<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>
1617
<li><a data-cy="cookie-login">Login with Social</a></li>
1718
<li><a data-cy="cookie-login-https">Login with Social (https)</a></li>
1819
<li><a data-cy="cookie-login-subdomain">Login with Social (subdomain)</a></li>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<!DOCTYPE html>
2+
<!-- NOTE: This is an EJS template used by the origin/integrity.cy.ts to test regex rewriting integrity -->
3+
<!-- using this fixture without compiling and rendering the template will cause errors -->
4+
<html>
5+
<head>
6+
<title>DOM Fixture</title>
7+
</head>
8+
<body>
9+
<h1 data-cy="integrity-header">Integrity Scripts</h1>
10+
</body>
11+
<% if(data && data.staticLinkInjection) { %>
12+
<!-- static link injection -->
13+
<!-- the actual integrity of the file is: <%=data.integrityValue%> -->
14+
<link id="static-set-integrity-link" rel="stylesheet" href="integrity.css" integrity="<%=data.integrityValue%>">
15+
<% } %>
16+
17+
<% if(data && data.staticScriptInjection) { %>
18+
<!-- static script injection -->
19+
<!-- the actual integrity of the file is: <%=data.integrityValue%> -->
20+
<script id="static-set-integrity-script" type="text/javascript" src="integrity.js" data-script-type="static" crossorigin="anonymous" integrity="<%=data.integrityValue%>"></script>
21+
<% } %>
22+
23+
<% if(data && data.dynamicScriptInjection) { %>
24+
<!-- dynamic script injection-->
25+
<script type="text/javascript">
26+
const dynamicIntegrityScript = document.createElement('script')
27+
dynamicIntegrityScript.id = 'dynamic-set-integrity-script'
28+
dynamicIntegrityScript.type = 'text/javascript'
29+
dynamicIntegrityScript.src = 'integrity.js'
30+
dynamicIntegrityScript.setAttribute('crossorigin', "anonymous")
31+
dynamicIntegrityScript.setAttribute('data-script-type', 'dynamic')
32+
// the actual integrity of the file is: <%=data.integrityValue%>
33+
dynamicIntegrityScript.setAttribute('integrity', "<%=data.integrityValue%>")
34+
35+
document.querySelector('head').appendChild(dynamicIntegrityScript)
36+
</script>
37+
<% } %>
38+
39+
<% if(data && data.dynamicLinkInjection) { %>
40+
<!-- dynamic link injection -->
41+
<script id="dynamic-link-injection" type="text/javascript">
42+
const dynamicIntegrityScript = document.createElement('link')
43+
dynamicIntegrityScript.id = 'dynamic-set-integrity-link'
44+
dynamicIntegrityScript.rel = "stylesheet"
45+
dynamicIntegrityScript.href = 'integrity.css'
46+
dynamicIntegrityScript.setAttribute('crossorigin', "anonymous")
47+
// the actual integrity of the file is: <%=data.integrityValue%>
48+
dynamicIntegrityScript.setAttribute('integrity', "<%=data.integrityValue%>")
49+
50+
document.querySelector('head').appendChild(dynamicIntegrityScript)
51+
</script>
52+
<% } %>
53+
</html>

packages/driver/cypress/fixtures/secondary-origin.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<p data-cy="cypress-check"></p>
88
<p data-cy="window-before-load"></p>
99
<input data-cy="text-input" type="text"/>
10-
<form>
10+
<form action="/fixtures/generic.html" method="get" target="_top">
1111
<button type="submit">Submit</button>
1212
</form>
1313
<button data-cy="alert">Alert</button>

packages/driver/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
"clean-deps": "rimraf node_modules",
77
"cypress:open": "node ../../scripts/cypress open",
88
"cypress:run": "node ../../scripts/cypress run --spec \"cypress/e2e/*/*\",\"cypress/e2e/*/!(origin|sessions)/**/*\"",
9-
"cypress:open-experimentalSessionAndOrigin": "node ../../scripts/cypress open --config experimentalSessionAndOrigin=true",
10-
"cypress:run-experimentalSessionAndOrigin": "node ../../scripts/cypress run --config experimentalSessionAndOrigin=true",
9+
"cypress:open-experimentalSessionAndOrigin": "node ../../scripts/cypress open --config experimentalSessionAndOrigin=true,experimentalModifyObstructiveThirdPartyCode=true",
10+
"cypress:run-experimentalSessionAndOrigin": "node ../../scripts/cypress run --config experimentalSessionAndOrigin=true,experimentalModifyObstructiveThirdPartyCode=true",
1111
"postinstall": "patch-package",
1212
"start": "node -e 'console.log(require(`chalk`).red(`\nError:\n\tRunning \\`yarn start\\` is no longer needed for driver/cypress tests.\n\tWe now automatically spawn the server in e2e.setupNodeEvents config.\n\tChanges to the server will be watched and reloaded automatically.`))'"
1313
},
@@ -20,6 +20,7 @@
2020
"@cypress/what-is-circular": "1.0.1",
2121
"@packages/config": "0.0.0-development",
2222
"@packages/network": "0.0.0-development",
23+
"@packages/rewriter": "0.0.0-development",
2324
"@packages/runner": "0.0.0-development",
2425
"@packages/runner-shared": "0.0.0-development",
2526
"@packages/server": "0.0.0-development",
@@ -45,6 +46,7 @@
4546
"cookie-parser": "1.4.5",
4647
"core-js-pure": "3.21.0",
4748
"cors": "2.8.5",
49+
"crypto-js": "4.1.1",
4850
"cypress-multi-reporters": "1.4.0",
4951
"dayjs": "^1.10.3",
5052
"debug": "^4.3.2",

packages/driver/src/cross-origin/cypress.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import { handleScreenshots } from './events/screenshots'
2020
import { handleTestEvents } from './events/test'
2121
import { handleMiscEvents } from './events/misc'
2222
import { handleUnsupportedAPIs } from './unsupported_apis'
23+
import { patchDocumentCookie } from './patches/cookies'
24+
import { patchFormElementSubmit } from './patches/submit'
25+
import { patchElementIntegrity } from './patches/setAttribute'
2326
import $Mocha from '../cypress/mocha'
2427
import * as cors from '@packages/network/lib/cors'
2528

@@ -105,6 +108,13 @@ const onBeforeAppWindowLoad = (Cypress: Cypress.Cypress, cy: $Cy) => (autWindow:
105108
Cypress.state('window', autWindow)
106109
Cypress.state('document', autWindow.document)
107110

111+
if (Cypress && Cypress.config('experimentalModifyObstructiveThirdPartyCode')) {
112+
patchFormElementSubmit(autWindow)
113+
patchElementIntegrity(autWindow)
114+
}
115+
116+
patchDocumentCookie(Cypress, autWindow)
117+
108118
// This is typically called by the cy function `urlNavigationEvent` but it is private. For the primary origin this is called in 'onBeforeAppWindowLoad'.
109119
Cypress.action('app:navigation:changed', 'page navigation event (\'before:load\')')
110120

0 commit comments

Comments
 (0)