Skip to content

Commit 02aa75d

Browse files
authored
feat: add removeSRIAttributes config option to strip SRI from first-party resources (#34127)
1 parent 2d6b11e commit 02aa75d

22 files changed

Lines changed: 338 additions & 39 deletions

File tree

cli/CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
<!-- See ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
2-
## 15.17.1
2+
## 15.18.0
33

44
**Performance:**
55

66
- Fixed an issue where an application that repeatedly threw the same uncaught exception (for example, a benign `ResizeObserver loop ...` notification fired on every animation frame) could exhaust renderer memory and crash the browser. Consecutive identical uncaught exceptions within a test now collapse into a single, updating command-log entry, and a handled (suppressed) uncaught exception no longer captures a DOM snapshot. Addresses [#27415](https://github.com/cypress-io/cypress/issues/27415).
77

8+
**Features:**
9+
10+
- Added a `removeSRIAttributes` configuration option. When enabled, Cypress strips the `integrity` attribute from `<script>` and `<link>` elements on first-party resources so they are not blocked by Subresource Integrity (SRI) enforcement after Cypress rewrites them. This covers `integrity` set via static HTML, a JavaScript string literal, and runtime DOM assignment (including the `webpack-subresource-integrity` pattern used for lazily-loaded chunks). Addresses [#34124](https://github.com/cypress-io/cypress/issues/34124) and [#18315](https://github.com/cypress-io/cypress/issues/18315).
11+
812
**Bugfixes:**
913

1014
- Fixed an issue where calling `req.destroy()` or using `{ forceNetworkError: true }` inside a [`cy.intercept()`](https://on.cypress.io/intercept) handler in experimental WebKit caused infinite request loops. Fixes [#23810](https://github.com/cypress-io/cypress/issues/23810). Fixed in [#33948](https://github.com/cypress-io/cypress/pull/33948).

cli/types/cypress.d.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3074,6 +3074,15 @@ declare namespace Cypress {
30743074
* @see https://on.cypress.io/configuration#modifyObstructiveCode
30753075
*/
30763076
modifyObstructiveCode: boolean
3077+
/**
3078+
* Whether Cypress will strip the `integrity` attribute from `<script>` and `<link>` elements
3079+
* on first-party resources so they are not blocked by Subresource Integrity (SRI) enforcement
3080+
* after the proxy rewrites them (under `modifyObstructiveCode`). Covers `integrity` set via
3081+
* static HTML, a JavaScript string literal, or runtime DOM assignment. Third-party resources
3082+
* are only rewritten — and have their SRI stripped — under `experimentalModifyObstructiveThirdPartyCode`.
3083+
* @default false
3084+
*/
3085+
removeSRIAttributes: boolean
30773086
/**
30783087
* Time, in milliseconds, to wait for an XHR request to go out in a [cy.wait()](https://on.cypress.io/wait) command
30793088
* @default 5000
@@ -3233,7 +3242,8 @@ declare namespace Cypress {
32333242
experimentalInteractiveRunEvents: boolean
32343243
/**
32353244
* Whether Cypress will search for and replace obstructive code in third party .js or .html files.
3236-
* NOTE: Setting this flag to true removes Subresource Integrity (SRI).
3245+
* NOTE: Setting this flag to true removes Subresource Integrity (SRI) from third-party resources.
3246+
* To strip SRI from first-party resources as well, use `removeSRIAttributes`.
32373247
* Please see https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity.
32383248
* This option has no impact on experimentalSourceRewriting and is only used with the
32393249
* non-experimental source rewriter.

packages/config/src/options.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,11 @@ const driverConfigOptions: Array<DriverConfigOption> = [
346346
defaultValue: 20,
347347
validation: validate.isNumber,
348348
overrideLevel: 'any',
349+
}, {
350+
name: 'removeSRIAttributes',
351+
defaultValue: false,
352+
validation: validate.isBoolean,
353+
requireRestartOnChange: 'server',
349354
}, {
350355
name: 'reporter',
351356
defaultValue: 'spec',

packages/config/test/__snapshots__/index.spec.ts.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ exports[`config/src/index > .getDefaultValues > returns list of public config ke
7171
"port": null,
7272
"projectId": null,
7373
"redirectionLimit": 20,
74+
"removeSRIAttributes": false,
7475
"repoRoot": null,
7576
"reporter": "spec",
7677
"reporterOptions": null,
@@ -168,6 +169,7 @@ exports[`config/src/index > .getDefaultValues > returns list of public config ke
168169
"port": null,
169170
"projectId": null,
170171
"redirectionLimit": 20,
172+
"removeSRIAttributes": false,
171173
"repoRoot": null,
172174
"reporter": "spec",
173175
"reporterOptions": null,
@@ -247,6 +249,7 @@ exports[`config/src/index > .getPublicConfigKeys > returns list of public config
247249
"port",
248250
"projectId",
249251
"redirectionLimit",
252+
"removeSRIAttributes",
250253
"reporter",
251254
"reporterOptions",
252255
"requestTimeout",

packages/config/test/project/utils.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1335,6 +1335,7 @@ describe('config/src/project/utils', () => {
13351335
port: { value: 1234, from: 'cli' },
13361336
projectId: { value: null, from: 'default' },
13371337
redirectionLimit: { value: 20, from: 'default' },
1338+
removeSRIAttributes: { value: false, from: 'default' },
13381339
reporter: { value: 'json', from: 'cli' },
13391340
resolvedNodePath: { value: null, from: 'default' },
13401341
resolvedNodeVersion: { value: null, from: 'default' },
@@ -1468,6 +1469,7 @@ describe('config/src/project/utils', () => {
14681469
port: { value: 2020, from: 'config' },
14691470
projectId: { value: 'projectId123', from: 'env' },
14701471
redirectionLimit: { value: 20, from: 'default' },
1472+
removeSRIAttributes: { value: false, from: 'default' },
14711473
reporter: { value: 'spec', from: 'default' },
14721474
resolvedNodePath: { value: null, from: 'default' },
14731475
resolvedNodeVersion: { value: null, from: 'default' },

packages/driver/cypress.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const baseConfig: Cypress.ConfigOptions = {
2727
e2e: {
2828
experimentalOriginDependencies: true,
2929
experimentalModifyObstructiveThirdPartyCode: true,
30+
removeSRIAttributes: true,
3031
setupNodeEvents: (on, config) => {
3132
on('task', {
3233
log (message) {

packages/driver/cypress/e2e/e2e/encoding.cy.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,13 @@ describe('encoding', () => {
177177

178178
// in firefox, the browser displays the encoded response whereas in other browsers, it fails to load the page
179179
if (Cypress.isBrowser({ family: 'firefox' })) {
180-
cy.get('body').should('contain.text', ' �������t�Ѓ�*��z').then(() => done())
180+
cy.get('body')
181+
// firefox renders the raw, undecoded brotli bytes as text rather than failing to load.
182+
// Assert on that garbled state rather than the exact compressed bytes, which vary with
183+
// the proxy-injected runner bundle.
184+
.should('contain.text', '\uFFFD')
185+
.and('not.contain.text', 'encoding-br-html')
186+
.then(() => done())
181187
} else {
182188
cy.on('fail', (err) => {
183189
expect(err.message).to.contain('Timed out after waiting `500ms` for your remote page to load.')
@@ -191,7 +197,13 @@ describe('encoding', () => {
191197

192198
// in firefox, the browser displays the encoded response whereas in other browsers, it fails to load the page
193199
if (Cypress.isBrowser({ family: 'firefox' })) {
194-
cy.get('body').should('contain.text', ' �������t�Ѓ�*��z').then(() => done())
200+
cy.get('body')
201+
// firefox renders the raw, undecoded brotli bytes as text rather than failing to load.
202+
// Assert on that garbled state rather than the exact compressed bytes, which vary with
203+
// the proxy-injected runner bundle.
204+
.should('contain.text', '\uFFFD')
205+
.and('not.contain.text', 'encoding-br-html')
206+
.then(() => done())
195207
} else {
196208
cy.on('fail', (err) => {
197209
expect(err.message).to.contain('Timed out after waiting `500ms` for your remote page to load.')

packages/driver/cypress/e2e/e2e/origin/integrity.cy.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,29 @@ describe('Integrity Preservation', { browser: '!webkit' }, () => {
9494
cy.get('#dynamic-set-integrity-script').should('have.attr', 'cypress-stripped-integrity')
9595
})
9696
})
97+
98+
it(`preserves integrity with a <script> whose integrity property is set at runtime with ${algo} integrity.`, () => {
99+
cy.then(() => {
100+
const compiledTemplate = templateExecutor({
101+
dynamicScriptPropertyInjection: true,
102+
integrityValue: `${algo.toLowerCase()}-${integrityJSDigests[algo]}`,
103+
})
104+
105+
cy.intercept('http://www.foobar.com:3500/fixtures/scripts-with-integrity.html', compiledTemplate)
106+
})
107+
108+
cy.visit('fixtures/primary-origin.html')
109+
cy.get('[data-cy="integrity-link"]').click()
110+
cy.origin('http://www.foobar.com:3500', () => {
111+
// The added script, if integrity matches, should execute and
112+
// add a <p> element with 'integrity script loaded' as the text
113+
cy.get('#integrity', {
114+
timeout: 1000,
115+
}).should('contain', 'integrity script loaded')
116+
117+
cy.get('#dynamic-set-integrity-script').should('have.attr', 'cypress-stripped-integrity')
118+
})
119+
})
97120
})
98121
})
99122

@@ -148,6 +171,97 @@ describe('Integrity Preservation', { browser: '!webkit' }, () => {
148171
cy.get('#dynamic-set-integrity-link').should('have.attr', 'cypress-stripped-integrity')
149172
})
150173
})
174+
175+
it(`preserves integrity with a <link> whose integrity property is set at runtime with ${algo} integrity.`, () => {
176+
cy.then(() => {
177+
const compiledTemplate = templateExecutor({
178+
dynamicLinkPropertyInjection: true,
179+
integrityValue: `${algo.toLowerCase()}-${integrityCSSDigests[algo]}`,
180+
})
181+
182+
cy.intercept('http://www.foobar.com:3500/fixtures/scripts-with-integrity.html', compiledTemplate)
183+
})
184+
185+
cy.visit('fixtures/primary-origin.html')
186+
cy.get('[data-cy="integrity-link"]').click()
187+
cy.origin('http://www.foobar.com:3500', () => {
188+
cy.get('[data-cy="integrity-header"]', {
189+
timeout: 1000,
190+
}).then((integrityHeader) => {
191+
// The added link, if integrity matches, should execute and
192+
// add a color 'red' to the data-cy="integrity-header" element
193+
expect(window.getComputedStyle(integrityHeader[0]).getPropertyValue('color')).to.equal('rgb(255, 0, 0)')
194+
})
195+
196+
cy.get('#dynamic-set-integrity-link').should('have.attr', 'cypress-stripped-integrity')
197+
})
198+
})
199+
})
200+
})
201+
202+
// The cases above run cross-origin (handled by experimentalModifyObstructiveThirdPartyCode).
203+
// These exercise removeSRIAttributes: the resource is served first-party (rewritten by
204+
// modifyObstructiveCode, invalidating any pinned hash) in the primary AUT frame.
205+
describe('first-party resources (removeSRIAttributes)', () => {
206+
availableDigests.forEach((algo) => {
207+
it(`preserves integrity with static <script> in first-party HTML with ${algo} integrity.`, () => {
208+
cy.then(() => {
209+
const compiledTemplate = templateExecutor({
210+
staticScriptInjection: true,
211+
integrityValue: `${algo.toLowerCase()}-${integrityJSDigests[algo]}`,
212+
})
213+
214+
cy.intercept('http://localhost:3500/fixtures/scripts-with-integrity.html', compiledTemplate)
215+
})
216+
217+
cy.visit('fixtures/scripts-with-integrity.html')
218+
// The added script, if integrity matches, should execute and
219+
// add a <p> element with 'integrity script loaded' as the text
220+
cy.get('#integrity', {
221+
timeout: 1000,
222+
}).should('contain', 'integrity script loaded')
223+
224+
cy.get('#static-set-integrity-script').should('have.attr', 'cypress-stripped-integrity')
225+
})
226+
227+
it(`preserves integrity with a first-party <script> whose integrity property is set at runtime with ${algo} integrity.`, () => {
228+
cy.then(() => {
229+
const compiledTemplate = templateExecutor({
230+
dynamicScriptPropertyInjection: true,
231+
integrityValue: `${algo.toLowerCase()}-${integrityJSDigests[algo]}`,
232+
})
233+
234+
cy.intercept('http://localhost:3500/fixtures/scripts-with-integrity.html', compiledTemplate)
235+
})
236+
237+
cy.visit('fixtures/scripts-with-integrity.html')
238+
cy.get('#integrity', {
239+
timeout: 1000,
240+
}).should('contain', 'integrity script loaded')
241+
242+
cy.get('#dynamic-set-integrity-script').should('have.attr', 'cypress-stripped-integrity')
243+
})
244+
245+
it(`preserves integrity with a first-party <link> whose integrity property is set at runtime with ${algo} integrity.`, () => {
246+
cy.then(() => {
247+
const compiledTemplate = templateExecutor({
248+
dynamicLinkPropertyInjection: true,
249+
integrityValue: `${algo.toLowerCase()}-${integrityCSSDigests[algo]}`,
250+
})
251+
252+
cy.intercept('http://localhost:3500/fixtures/scripts-with-integrity.html', compiledTemplate)
253+
})
254+
255+
cy.visit('fixtures/scripts-with-integrity.html')
256+
cy.get('[data-cy="integrity-header"]', {
257+
timeout: 1000,
258+
}).then((integrityHeader) => {
259+
// The added link, if integrity matches, should apply a color 'red' to the header
260+
expect(window.getComputedStyle(integrityHeader[0]).getPropertyValue('color')).to.equal('rgb(255, 0, 0)')
261+
})
262+
263+
cy.get('#dynamic-set-integrity-link').should('have.attr', 'cypress-stripped-integrity')
264+
})
151265
})
152266
})
153267
})

packages/driver/cypress/e2e/e2e/origin/patches.cy.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,42 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout
6969
})
7070
})
7171

72+
// el.integrity = … (the webpack-subresource-integrity pattern) is set via the reflected
73+
// property; a regex can't rewrite a non-literal value, so the setter is overridden at runtime.
74+
context('integrity property', () => {
75+
beforeEach(() => {
76+
cy.visit('/fixtures/primary-origin.html')
77+
cy.get('a[data-cy="cross-origin-secondary-link"]').click()
78+
})
79+
80+
it('redirects the reflected integrity property to cypress-stripped-integrity for HTMLScriptElement', () => {
81+
cy.origin('http://www.foobar.com:3500', () => {
82+
cy.window().then((win: Window) => {
83+
const script = win.document.createElement('script')
84+
85+
script.integrity = 'sha-123'
86+
expect(script.getAttribute('integrity')).to.be.null
87+
expect(script.getAttribute('cypress-stripped-integrity')).to.equal('sha-123')
88+
// the getter still reflects the value back so app code reading it is unaffected
89+
expect(script.integrity).to.equal('sha-123')
90+
})
91+
})
92+
})
93+
94+
it('redirects the reflected integrity property to cypress-stripped-integrity for HTMLLinkElement', () => {
95+
cy.origin('http://www.foobar.com:3500', () => {
96+
cy.window().then((win: Window) => {
97+
const link = win.document.createElement('link')
98+
99+
link.integrity = 'sha-123'
100+
expect(link.getAttribute('integrity')).to.be.null
101+
expect(link.getAttribute('cypress-stripped-integrity')).to.equal('sha-123')
102+
expect(link.integrity).to.equal('sha-123')
103+
})
104+
})
105+
})
106+
})
107+
72108
context('fetch', () => {
73109
describe('from the AUT', () => {
74110
beforeEach(() => {

packages/driver/cypress/fixtures/scripts-with-integrity.html

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,40 @@ <h1 data-cy="integrity-header">Integrity Scripts</h1>
4646
dynamicIntegrityScript.setAttribute('crossorigin', "anonymous")
4747
// the actual integrity of the file is: <%=data.integrityValue%>
4848
dynamicIntegrityScript.setAttribute('integrity', "<%=data.integrityValue%>")
49-
49+
50+
document.querySelector('head').appendChild(dynamicIntegrityScript)
51+
</script>
52+
<% } %>
53+
54+
<% if(data && data.dynamicScriptPropertyInjection) { %>
55+
<!-- dynamic script injection, integrity set via the reflected property from a variable (webpack-subresource-integrity pattern) -->
56+
<script type="text/javascript">
57+
const dynamicIntegrityScript = document.createElement('script')
58+
dynamicIntegrityScript.id = 'dynamic-set-integrity-script'
59+
dynamicIntegrityScript.type = 'text/javascript'
60+
dynamicIntegrityScript.src = 'integrity.js'
61+
dynamicIntegrityScript.setAttribute('crossorigin', "anonymous")
62+
dynamicIntegrityScript.setAttribute('data-script-type', 'dynamic')
63+
// the actual integrity of the file is: <%=data.integrityValue%>
64+
const scriptIntegrity = "<%=data.integrityValue%>"
65+
dynamicIntegrityScript.integrity = scriptIntegrity
66+
67+
document.querySelector('head').appendChild(dynamicIntegrityScript)
68+
</script>
69+
<% } %>
70+
71+
<% if(data && data.dynamicLinkPropertyInjection) { %>
72+
<!-- dynamic link injection, integrity set via the reflected property from a variable -->
73+
<script id="dynamic-link-property-injection" type="text/javascript">
74+
const dynamicIntegrityScript = document.createElement('link')
75+
dynamicIntegrityScript.id = 'dynamic-set-integrity-link'
76+
dynamicIntegrityScript.rel = "stylesheet"
77+
dynamicIntegrityScript.href = 'integrity.css'
78+
dynamicIntegrityScript.setAttribute('crossorigin', "anonymous")
79+
// the actual integrity of the file is: <%=data.integrityValue%>
80+
const linkIntegrity = "<%=data.integrityValue%>"
81+
dynamicIntegrityScript.integrity = linkIntegrity
82+
5083
document.querySelector('head').appendChild(dynamicIntegrityScript)
5184
</script>
5285
<% } %>

0 commit comments

Comments
 (0)