Gleb Bahmutov PRO
JavaScript ninja, image processing expert, software quality fanatic
Global temperature anomaly 2022 https://climate.nasa.gov/vital-signs/global-temperature/
C / C++ / C# / Java / CoffeeScript / JavaScript / Node / Angular / Vue / Cycle.js / functional programming / testing
glebbahmutov.com/blog testing posts
Enter your username / password / credit card
You can have your
website hacked but only once.
Open Worldwide Application Security Project (OWASP)
Code injection attacks
=
confusion between data and code
<html>
<head>
<style>...</style>
<script src="https://code.jquery.com/3.5.0/jquery.min.js"></script>
<script>
...
</script>
</head>
<body>
<h1>Chat</h1>
<ul>
<li>Chat message 1</li>
<li>Chat message 2</li>
</ul>
</body>
</html>
<html>
<head>
<style>...</style>
<script src="https://code.jquery.com/3.5.0/jquery.min.js"></script>
<script>
...
</script>
</head>
<body>
<h1>Chat</h1>
<ul>
<li>Chat message 1</li>
<li>Chat message 2</li>
</ul>
</body>
</html>
Data
<html>
<head>
<style>...</style>
<script src="https://code.jquery.com/3.5.0/jquery.min.js"></script>
<script>
...
</script>
</head>
<body>
<h1>Chat</h1>
<ul>
<li>Chat message 1</li>
<li>Chat message 2</li>
</ul>
</body>
</html>
Code
Name:
John Doe
<span>{{ name }}<span>
<span>John Doe<span>
Name:
<script>alert('hi')</script>
<span>{{ name }}<span>
<span><script>alert('hi')</script><span>
innerText or innerHTML?
do you use eval(user input) anywhere???
Name:
<script>alert('hi')</script>
<span>{{ name }}<span>
<span><script>alert('hi')</script><span>
innerText or innerHTML?
do you use eval(user input) anywhere???
User input (data) is treated as code
innerText or innerHTML?
Dangerous, but has styling, elements, etc
Really dangerous if one user's content
can be viewed by another user
Will a script embedded in the user-entered content be executed (for another user)?
NEVER EVER EVER?
My content
User comments
Ads, etc
<script>stealStuff()</script>
// app.js
send$.addEventListener('click', () => {
const message = message$.value
const li = document.createElement('li')
li.innerText = message
messages$.appendChild(li)
message$.value = ''
send$.setAttribute('disabled', 'disabled')
})
// innerText (safe)
// cypress/e2e/spec.cy.js
it('adds a new message', () => {
cy.visit('/')
cy.contains('button', 'Send').should('be.disabled')
cy.get('#message').type('Hello')
cy.contains('button', 'Send').click()
cy.get('#messages li')
.should('have.length', 1).and('have.text', 'Hello')
cy.get('#message').should('have.value', '')
cy.contains('button', 'Send').should('be.disabled')
})
Cypress end-to-end test
// cypress/e2e/spec.cy.js
it('adds a new message', () => {
cy.visit('/')
cy.contains('button', 'Send').should('be.disabled')
cy.get('#message').type('Hello')
cy.contains('button', 'Send').click()
cy.get('#messages li')
.should('have.length', 1).and('have.text', 'Hello')
cy.get('#message').should('have.value', '')
cy.contains('button', 'Send').should('be.disabled')
})
Cypress end-to-end test
Cypress tests commands slowed by 1 second each using cypress-slow-down plugin
Estimate: 3 points
Priority: P0
// app.js
send$.addEventListener('click', () => {
const message = message$.value
const li = document.createElement('li')
li.innerHTML = message
messages$.appendChild(li)
message$.value = ''
send$.setAttribute('disabled', 'disabled')
})
// innerHTML (for now)
// cypress/e2e/spec.cy.js
it('adds a bold message', () => {
cy.visit('/')
cy.get('#message').type('<b>Important</b>')
cy.contains('button', 'Send').click()
cy.get('#messages li b')
.should('have.length', 1)
.and('have.text', 'Important')
})
// cypress/e2e/spec.cy.js
it('adds a bold message', () => {
cy.visit('/')
cy.get('#message').type('<b>Important</b>')
cy.contains('button', 'Send').click()
cy.get('#messages li b')
.should('have.length', 1)
.and('have.text', 'Important')
})
// cypress/e2e/spec.cy.js
it('injecting <script> tag does not work', () => {
cy.on('window:load', (win) => cy.stub(win.console, 'log').as('log'))
cy.visit('/')
cy.get('#message').type('Hello<script>console.log(`hacked`)</script>')
cy.contains('button', 'Send').click()
cy.contains('#messages li', 'Hello')
cy.get('@log').should('not.have.been.called')
})
// cypress/e2e/spec.cy.js
it('injecting <script> tag does not work', () => {
cy.on('window:load', (win) => cy.stub(win.console, 'log').as('log'))
cy.visit('/')
cy.get('#message').type('Hello<script>console.log(`hacked`)</script>')
cy.contains('button', 'Send').click()
cy.contains('#messages li', 'Hello')
cy.get('@log').should('not.have.been.called')
})
// cypress/e2e/spec.cy.js
it('injects XSS via img onerror attribute', () => {
cy.on('window:load', (win) =>
cy.stub(win.console, 'log').as('log'))
cy.visit('/')
cy.get('#message')
.type('Hello<img src="" onerror="console.log(`hacked`)" />')
cy.contains('button', 'Send').click()
cy.contains('#messages li', 'Hello')
cy.get('@log').should('have.been.calledWith', 'hacked')
})
// cypress/e2e/spec.cy.js
it('injects XSS via img onerror attribute', () => {
cy.on('window:load', (win) =>
cy.stub(win.console, 'log').as('log'))
cy.visit('/')
cy.get('#message')
.type('Hello<img src="" onerror="console.log(`hacked`)" />')
cy.contains('button', 'Send').click()
cy.contains('#messages li', 'Hello')
cy.get('@log').should('have.been.calledWith', 'hacked')
})
No, we are not
// app.js
send$.addEventListener('click', () => {
const message = message$.value
const li = document.createElement('li')
li.innerHTML = removeBadTags(message)
messages$.appendChild(li)
message$.value = ''
send$.setAttribute('disabled', 'disabled')
})
it('injects XSS via b onclick attribute', () => {
cy.on('window:load', (win) =>
cy.stub(win.console, 'log').as('log'))
cy.visit('/')
cy.get('#message')
.type('<b onclick="console.log(`hacked`)">Hello</b>')
cy.contains('button', 'Send').click()
cy.contains('#messages li b', 'Hello').click()
cy.get('@log').should('have.been.calledWith', 'hacked')
})
it('injects XSS via b onclick attribute', () => {
cy.on('window:load', (win) =>
cy.stub(win.console, 'log').as('log'))
cy.visit('/')
cy.get('#message')
.type('<b onclick="console.log(`hacked`)">Hello</b>')
cy.contains('button', 'Send').click()
cy.contains('#messages li b', 'Hello').click()
cy.get('@log').should('have.been.calledWith', 'hacked')
})
Angular 1 edition (dated)
Examples where we can embed script code into {{ }} template
{{constructor.constructor('alert(1)')()}}
fixed in Angular 1.2
{{ (_=''.sub).call.call({}[$='constructor'].getOwnPropertyDescriptor ( _.__proto__,$).value,0,'alert(1)')() }}
{{ objectPrototype = ({})[['__proto__']]; objectPrototype[['__defineSetter__']]('$parent', $root.$$postDigest); $root.$$listenerCount[['constructor']] = 0; $root.$$listeners = [].map; $root.$$listeners.indexOf = [].map.bind; functionPrototype = [].map[['__proto__']]; functionToString = functionPrototype.toString; functionPrototype.push = ({}).valueOf; functionPrototype.indexOf = [].map.bind; foo = $root.$on('constructor', null); functionPrototype.toString = $root.$new; foo(); }} {{ functionPrototype.toString = functionToString; functionPrototype.indexOf = null; functionPrototype.push = null; $root.$$listeners = {}; baz ? 0 : $root.$$postDigestQueue[0]('alert(location)')(); baz = true;'' }}
{{ 'this is how you write a number properly. also, numbers are basically arrays.'; 0[['__proto__']].toString = [][['__proto__']].pop; 0[['__proto__']][0] = 'alert("TROLOLOLn"+document.location)'; 0[['__proto__']].length = 1; 'did you know that angularjs eval parses, then re-stringifies numbers? :)'; $root.$eval("x=0", $root); }}
At least: do not write the sanitizer yourself, use https://github.com/cure53/DOMPurify
Estimate: 1 point
Priority: P2
<body>
<script>function willNotRun() {...}</script>
<script src="assets/js/script.js"></script>
</body>
allowed from the right domain
never* allowed
* or with specific exceptions
* tip: use exact library URLs and integrity checksums
<meta http-equiv="Content-Security-Policy"
content="script-src https://code.jquery.com 'self';">
Wide support for CSP (browsers, web apps)
or even better
Content-Security-Policy: default-src 'self' example.com *.example.com
Content-Security-Policy: default-src 'self'; img-src *; media-src example.org example.net; script-src userscripts.example.com
Specify trusted sources for scripts, fonts, styles, images, media, frames, etc
// server JS
const express = require('express')
const helmet = require('helmet')
const app = express()
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
// ughh, have to allow unsafe inline styles to be added by Cypress
// https://github.com/cypress-io/cypress/issues/21374
styleSrc: ["'self'", "'unsafe-inline'"],
reportUri: ['/security-attacks'],
},
},
}),
)
// cypress.config.js
// https://on.cypress.io/experiments
// https://github.com/cypress-io/cypress/issues/1030
experimentalCspAllowList: ['default-src', 'script-src'],
// cypress/e2e/spec.cy.js
it('stops XSS', () => {
cy.on('window:load', (win) =>
cy.stub(win.console, 'log').as('log'))
cy.visit('/')
cy.get('#message')
.type('Hello<img src="" onerror="console.log(`hacked`)" />')
cy.contains('button', 'Send').click()
cy.contains('#messages li', 'Hello')
cy.get('@log').should('not.be.called')
})
// cypress.config.js
// https://on.cypress.io/experiments
// https://github.com/cypress-io/cypress/issues/1030
experimentalCspAllowList: ['default-src', 'script-src'],
// cypress/e2e/spec.cy.js
it('stops XSS', () => {
cy.on('window:load', (win) =>
cy.stub(win.console, 'log').as('log'))
cy.visit('/')
cy.get('#message')
.type('Hello<img src="" onerror="console.log(`hacked`)" />')
cy.contains('button', 'Send').click()
cy.contains('#messages li', 'Hello')
cy.get('@log').should('not.be.called')
})
// server JS
const express = require('express')
const helmet = require('helmet')
const app = express()
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
// ughh, have to allow unsafe inline styles to be added by Cypress
// https://github.com/cypress-io/cypress/issues/21374
styleSrc: ["'self'", "'unsafe-inline'"],
reportUri: ['/security-attacks'],
},
},
}),
)
// cypress/e2e/spec.cy.js
it('stops XSS and reports CSP violations', () => {
cy.intercept('/security-attacks', {}).as('cspAttacks')
cy.on('window:load', (win) => cy.stub(win.console, 'log').as('log'))
cy.visit('/')
cy.get('#message').type('Hello<img src="" onerror="console.log(`hacked`)" />')
cy.contains('button', 'Send').click()
cy.contains('#messages li', 'Hello')
cy.log('**XSS stopped and reported**')
cy.wait('@cspAttacks').its('request.body').should('include', 'blocked')
cy.get('@log').should('not.be.called')
})
// cypress/e2e/spec.cy.js
it('stops XSS and reports CSP violations', () => {
cy.intercept('/security-attacks', {}).as('cspAttacks')
cy.on('window:load', (win) => cy.stub(win.console, 'log').as('log'))
cy.visit('/')
cy.get('#message').type('Hello<img src="" onerror="console.log(`hacked`)" />')
cy.contains('button', 'Send').click()
cy.contains('#messages li', 'Hello')
cy.log('**XSS stopped and reported**')
cy.wait('@cspAttacks').its('request.body').should('include', 'blocked')
cy.get('@log').should('not.be.called')
})
Code injection attacks are common
Use content security policy
Write tests to check your CSP directives
Verify the violation reporting works
💡 Trusted types experimental browser feature https://web.dev/trusted-types/
👏 Thank You 👏
By Gleb Bahmutov
Script injection attacks can load the attacker's code and run it on your website when other users browse it. You can prevent such attacks using content security policies, but how do you ensure your defense mechanisms actually work? By testing them! In this talk, I explain the content-security-policy, the security violation reporting, and how we can write a Cypress test to verify the attacks are stopped.
JavaScript ninja, image processing expert, software quality fanatic