Use Cypress Task To Avoid Cypress Env Variable

Using the operating system environment variable to avoid injecting Cypress variable into the browser.

Let's take an application where the test makes a request to an external site using cy.request command. Without authentication header, the request fails.

1
2
3
4
5
6
7
8
it('makes the request without auth', () => {
cy.request({
url: '/protected/secret',
failOnStatusCode: false,
})
.its('status')
.should('equal', 404)
})

Request fails without authorization

🎁 You can find the full source code for this blog post in bahmutov/cypress-env-var-example repo.

The server

Our server is very simple, it looks at Authorization request header to allow the request

server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const handler = require('serve-handler')
const http = require('http')

const server = http.createServer((request, response) => {
if (request.method === 'GET' && request.url === '/protected/secret') {
if (request.headers.authorization === 'Bearer XYZ123') {
response.writeHead(200, { 'content-type': 'application/json' })
response.end(JSON.stringify({ secret: 'abc909' }))
} else {
response.statusCode = 404
response.end()
}
return
}
return handler(request, response, {
public: './public',
})
})

server.listen(5000, () => {
console.log('Running at http://localhost:5000')
})

Authorized request

We can pass the authorization when making the call using the cy.request command

1
2
3
4
5
6
7
8
9
10
it('makes the request with hard-coded auth', () => {
cy.request({
url: '/protected/secret',
auth: {
bearer: 'XYZ123',
},
})
.should('have.property', 'body')
.should('deep.equal', { secret: 'abc909' })
})

Authorized request succeeds

We probably do not want to hard-code the bearer token in the spec file, thus we can move it into the Cypress.env variables as I described in Keep passwords secret in E2E tests blog post.

1
2
3
4
5
6
7
8
9
10
11
12
it('makes the request with auth from Cypress.env', () => {
cy.visit('/')
const bearer = Cypress.env('bearer')
cy.request({
url: '/protected/secret',
auth: {
bearer,
},
})
.should('have.property', 'body')
.should('deep.equal', { secret: 'abc909' })
})

We would open or run Cypress with the environment variable bearer set. For example:

1
$ npx cypress open --env bearer=XYZ123

Is it safe?

Any browser code can access the Cypress.env object. Since Cypress tests run in the browser, and Cypress sets the window.Cypress reference, then the application code can read the bearer token.

public/index.html
1
2
3
4
5
6
7
8
<body>
<h1>Example</h1>
<script>
if (window.Cypress) {
console.log('App has bearer', Cypress.env('bearer'))
}
</script>
</body>

JavaScript loaded by the page under test grabbed the Cypress.env variable

Is this a huge security hole? No. If your page loads untrusted code like this, the game is over already. But still, you do not want to give out private tokens via shared Cypress.env object.

Move the request to Node

Luckily, Cypress has its part running in Node environment, safely isolated from the browser window. You can move the request and call it via cy.task command.

cypress.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const { defineConfig } = require('cypress')

console.log('version', process.version)

module.exports = defineConfig({
e2e: {
// baseUrl, etc
baseUrl: 'http://localhost:5000',
setupNodeEvents(on, config) {
on('task', {
async getSecret() {
if (!process.env.BEARER) {
throw new Error('Missing BEARER')
}
const url = `${config.baseUrl}/protected/secret`
console.log('fetching %s', url)

// https://www.codewithyou.com/blog/finally-we-can-use-fetch-api-in-nodejs
const response = await fetch(url, {
headers: {
authorization: `Bearer ${process.env.BEARER}`,
},
})

if (!response.ok) {
throw new Error('Network response was not ok')
}

const data = await response.json()
return data
},
})
},
},
})

Tip: inside the callback setupNodeEvents(on, config) I am using config.baseUrl to form the URL to fetch:

1
const url = `${config.baseUrl}/protected/secret`

This allows me to control the base URL via the standard Cypress configuration methods. For example, if I am testing a deployed server via CYPRESS_baseUrl=https://staging.acme.co BEARER=XYZ123 npx cypress run no changes would be necessary.

I am using Node v16.17.0 to run Cypress, thus I need to enable the experimental fetch support and pass the BEARER as an operating system environment variable.

1
$ BEARER=XYZ123 NODE_OPTIONS=--experimental-fetch npx cypress open

Requesting the secret using cy.task to avoid storing a sensitive variable in Cypress.env object

Transition from Cypress.env to the operating system environment variable

If you are using CYPRESS_ variable and want to move to use a plain operating system variable, you might have to tell every member of the team to change how they inject env variables. I hope they use as-a already, but even then they would need to change their local secrets. Thus when transitioning, I advise warning about the CYPRESS_ variable and removing it from the config.env object in your cypress.config.js file

cypress.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
setupNodeEvents(on, config) {
if (config.env.bearer) {
console.error('⚠️ Found CYPRESS_BEARER variable')
console.error(
'⚠️ Please use plain operating system BEARER to inject the auth header',
)
process.env.BEARER = config.env.bearer
config.env.bearer = null
}

on('task', { ... })

// IMPORTANT: return the changed config
// to make sure we removed the BEARER from config.env object
return config
}

Let's say someone is still using CYPRESS_BEARER=.... The variable has been "moved" to process.env and removed from config.env object by setting it to null.

1
2
3
$ CYPRESS_bearer=XYZ123 NODE_OPTIONS=--experimental-fetch npx cypress open
⚠️ Found CYPRESS_BEARER variable
⚠️ Please use plain operating system BEARER to inject the auth header

The variable BEARER has been removed from the Cypress.env object

The CYPRESS_ variable has been removed on project load

Can application code call a Cypress task?

If we move the secret token to the Cypress Node process and access it from the spec file by calling cy.task('getSecret'), we have not cut off the application code from accessing it. Turns out, the Cypress global object has a reference to the cy object, which has a low-level now command that can execute any Cypress command out of band.

public/index.html
1
2
3
4
5
6
7
8
9
10
11
<body>
<h1>Example</h1>
<script>
if (window.Cypress) {
console.log('App has bearer', Cypress.env('bearer'))
window.Cypress.cy.now('task', 'getSecret').then((s) => {
console.log('App got secret %o', s)
})
}
</script>
</body>

The application code calling a Cypress task

In my opinion, setting the full Cypress object reference as window.Cypress in the application's iframe is a mistake. The test runner should only set window.Cypress object with the current spec / test information, and nothing more. Luckily we can do it ourselves from the E2E support file that loads before each spec and before the application code loads.

cypress/support/e2e.js
1
2
3
Cypress.on('window:before:load', (win) => {
win.Cypress = Cypress.currentTest
})

Let's update the application code a little bit to be defensive about trying to access the Cypress environment variables and cy.now method:

public/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<body>
<h1>Example</h1>
<script>
if (window.Cypress) {
if (Cypress.env) {
console.log('App has bearer', Cypress.env('bearer'))
} else {
console.log('there is no Cypress.env')
}

if (window.Cypress.cy) {
window.Cypress.cy.now('task', 'getSecret').then((s) => {
console.log('App got secret %o', s)
})
} else {
console.log('there is no Cypress.cy')
}
}
</script>
</body>

Running the test shows we are safe(r) now!

Pages under test only have the Cypress test title

Stay safe out there!

See also