- Visible password
- Do not hardcode passwords
- Validate password
- Avoid UI login
- Continuous integration
- Plugins
- Conclusions
- Bonus 1: hide the email
Note: source code for this blog post is the repository bahmutov/keep-password-secret.
Visible password
Imagine we have a password-protected web application. For example I have cloned passport/express-4.x-local-example - you need to enter login and password to see your personal profile. We can easily write an end-to-end test for this application using Cypress test runner.
1 | it('logs in', () => { |
This test passes, as you can see from the test run video. It also shows the danger: the password is clearly shown in the video during "TYPE" command!
In this blog post I will show how to ways to mitigate the security risk of showing secret information like the password text during end-to-end tests. In this case, the password is local - and it is probably fine to show it in plain text. But in other situations, when testing staging or production environment, the password to the test account should NOT be visible.
Do not hardcode passwords
The first thing I would do is to remove the hard-coded password value from the test files. Right now we have the password in each spec file source code as a string cy.type('secret')
, and instead I will pass it as an environment variable.
1 | it('logs in using env variables', () => { |
I believe in most cases, the username
can be public, while password
should be secret. Thus it is ok to store the username
in the env
object of cypress.json
file. And for completeness and clarity, I store an empty value for the password
key - this tells anyone reading the tests to expect to pass the password to make the tests work.
1 | { |
Great - if we run Cypress now, the test will fail, because cy.type refuses to type an empty string.
Cypress allows several ways to pass the environment variables, in this case, the secure way is to use an environment variable CYPRESS_password=...
when running Cypress. Cypress will stick all unknown environment variables that start with prefix CYPRESS_
into the env
object automatically.
1 | CYPRESS_password=secret npx cypress open|run |
When running tests locally I strongly recommend using my as-a utility to load groups of environment variables conveniently and cross-platform. In file ~/.as-a/.as-a.ini
I will add one more INI section and will place the secret password variable there.
1 | [kps] |
When running Cypress from my terminal I use
1 | as-a kps npx cypress open |
The password variable (and any other values in the block kps
) will be injected just for the duration of the above command.
On Continuous Integration server, just set a secure environment variable CYPRESS_password
to value secret
. Most CIs mask such values automatically in the logs.
Validate password
If the user forgets to open Cypress with CYPRESS_password=...
, or if the variable is not set on CI we get a cryptic error "TYPE cannot type an empty string". It would be much nicer to give a meaningful error if the password string is empty, right. Here is an updated test that first verifies the username and password values before using them.
1 | it('logs in using env variables', () => { |
It is working - if the developer is running Cypress test without CYPRESS_password=...
they will get a nice error.
But this is dangerous too - notice that the username assertion expect(username, 'username was set').to.be.a('string').and.not.be.empty
prints the value in the Command Log. If the password is set it will be visible in the video and any screenshot.
To avoid assertion values reflected in the Command Log, we must throw an error ourselves like this.
1 | const username = Cypress.env('username') |
Finally, we can avoid cy.type(password)
showing the value it is typing by using cy.type(password, {log: false})
option.
1 | cy.get('[name=password]').type(password, {log: false}) |
Now the Command Log will not reveal the secret password value in the video or screenshot
Finally, sometimes I see assertions chained at the end of cy.type
to confirm right away the value of the input element like this:
1 | cy.get('[name=username]') |
The assertion .should('have.value', ...)
also prints the value in the Command Log as shown below, revealing the password, even if just part of it in this case.
To avoid this, again we need to throw our own error by using should(callback)
assertion form.
1 | cy.get('[name=password]') |
The custom errors are not shown in the Command Log.
Avoid UI login
It is fine to test the Login user interface - but every test after that should NOT use UI to log in. It is slow and unnecessary. Instead, check how the web application performs the login by looking at the DevTools during UI login. In our case, the application is performing a POST request to /login
with the username and password form.
Find the recipe that matches this method among Cypress Logging in recipes. In this case it is HTML Web Form recipe. Copying the recipe to get the following test that uses cy.request
to make the form submission post and automatically sets any returned cookies.
1 | it('logs in using cy.request', () => { |
Beautiful, it is working, and we can make the login reusable by making it into a Cypress custom command or by creating a reusable function (which is simpler in my opinion). I will create and export login
function from cypress/support/index.js
file to be used in any test that wants to log in via API. Only tests that explicitly test the login page should not use this function - the rest can quickly login without going through the UI.
1 | /** |
I really like small reusable function, partly because I can document them using JSDoc comments, as above. This gives me intelligent code completion in any place I import and use login
function, for example from the spec file.
Continuous integration
To run Cypress test on CI I will use CircleCI - it is simple to set up, especially by using Cypress CircleCI Orb.
1 | version: 2.1 |
You can find the project at https://circleci.com/gh/bahmutov/keep-password-secret. The first run fails - because I have not set CYPRESS_password
environment variable yet.
We can set the required environment variable on CircleCI, but I really like the new CircleCI security contexts because they:
- allow explicitly listing the context that a job expects in the circle.yml file
- inject or stop the job depending on the security permission of the context
So I will create a new security context keep-password-secret
and will add the variable password there
Tip: longer passwords are better, because UI masking still reveals a half of the word!
Change the cypress/run
job by requiring the new context we have created
1 | version: 2.1 |
And the tests pass! Check out the test artifacts - you can see password in the video login-spec.js.mp4
that shows the original, insecure spec file.
But the password should not be visible in any other video - because we have modified the spec code to be less "chatty".
If you are using GitHub Actions, you can set the secrets using GitHub UI, and then pass explicitly each secret to an environment variable:
1 | name: Cypress tests |
Tip: the more about Cypress and GitHub Actions, take my course Testing The Swag Store.
Plugins
Watch out for any plugins you use with Cypress Test Runner - they might be printing secrets too. For example, I will add cypress-failed-log plugin that prints commands to the terminal.
1 | $ npm i -D cypress-failed-log |
The add to the support file the following
1 | require('cypress-failed-log') |
and to the plugins file
1 | module.exports = (on, config) => { |
Let's try this module. Let's run it locally WITH CYPRESS_password=secret
set. I will be using npm test
script
1 | { |
When running as-a kps npm test
everything runs ok - nothing sensitive is printed to the terminal. But make a test fail - for example like this
1 | it('logs in using env variables', () => { |
Let's test this - to make sure no sensitive information is printed on failure.
1 | $ as-a kps npx start-test 3000 \ |
The spec runs, fails - and the cypress-failed-log
plugin prints the each command - but does not reveal anything sensitive.
1 | 1) logs in using env variables |
Perfect. This plugin also saves a JSON file with the error - and the file does not reveal the password either.
1 | { |
Great - this plugin seems safe to use.
Conclusions
Keeping sensitive information out of public logs, screenshots and videos is a very important and ongoing concern. Make sure that every commit even if it only changes the tests goes through code review. If a password has been accidentally revealed, even during the pull request, revoke it and use a new one. For example, here is a quick way to get a random password using Node from the terminal
1 | $ node -p "crypto.randomBytes(16).toString('hex')" |
Read more about Cypress and ways to use it in the linked resourced
Bonus 1: hide the email
If you want to hide the text entered in other input elements, like the user email, you can change the element's type
attribute before typing.
1 | cy.get('form').within(() => { |
Watch the video Hide The Entered Email In The Form for details.