├── .gitignore ├── .travis.yml ├── README.md ├── app-vulnerable.js ├── bin ├── security-adventure.js └── verify.js ├── lib ├── menu.js ├── print-text.js ├── readme-sections.js ├── term-util.js ├── usage.txt └── workshopper.js ├── package.json └── test ├── lib ├── app-request.js ├── fiber-level.js ├── fiber-webdriver.js ├── index.js ├── util.js └── wdtest.js ├── problems ├── csp.js ├── csrf.js ├── html-escaping.js ├── httponly.js └── redos.js ├── solutions ├── csp.diff ├── csrf.diff ├── html-escaping.diff ├── httponly.diff ├── redos.diff └── test-solutions.js ├── test-app.js └── wdtest-app.js /.gitignore: -------------------------------------------------------------------------------- 1 | db 2 | node_modules 3 | app.js 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/toolness/security-adventure.png)](https://travis-ci.org/toolness/security-adventure) 2 | 3 | This repository contains an exciting quest to learn about Web security by 4 | learning about vulnerabilities, exploiting them, and then crafting code to 5 | protect against them. 6 | 7 | ## Prerequisites 8 | 9 | 10 | 11 | Before embarking on this adventure, make sure you have the skills taught in 12 | [learnyounode][] and [levelmeup][]. 13 | 14 | Also, make sure [phantomjs][] is installed and on your path (as well as 15 | node 0.10 and npm). 16 | 17 | 18 | 19 | ## Start The Adventure! 20 | 21 | ### Via npm 22 | 23 | Start the adventure like so: 24 | 25 | ``` 26 | sudo npm install -g security-adventure 27 | security-adventure 28 | ``` 29 | 30 | That's all there is to it; the `security-adventure` program will 31 | instruct you further. 32 | 33 | ### Via git 34 | 35 | Alternatively, you can clone this git repository and follow the 36 | instructions in the rest of this README: 37 | 38 | ``` 39 | git clone https://github.com/toolness/security-adventure.git 40 | cd security-adventure 41 | npm install 42 | cp app-vulnerable.js app.js 43 | ``` 44 | 45 | 46 | 47 | `app.js` is a full web application in about 150 lines of code that 48 | allows users to create password-protected accounts and store private 49 | plaintext notes in the cloud. 50 | 51 | Run `node app.js` and then browse to http://localhost:3000 to familiarize 52 | yourself with the behavior of the application by creating a user and saving 53 | some notes. Then read the source of `app.js` to get a basic idea of how 54 | everything works. 55 | 56 | `app.js` contains lots of vulnerabilities, and your quest is to learn about 57 | and fix all of them! 58 | 59 | 60 | 61 | ### Vulnerability: Regular Expression Denial of Service 62 | 63 | The regular expression used to validate usernames has a 64 | [Regular Expression Denial of Service][redos] vulnerability in it. 65 | 66 | Read about this vulnerability, and then try exploiting it manually by 67 | visiting the app in your browser and entering an invalid username that 68 | will cause the app to hang. 69 | 70 | Then fix `app.js`. 71 | 72 | Run `bin/verify.js redos` to verify that your solution works. 73 | 74 | 75 | 76 | ### Vulnerability: Reflected Cross Site Scripting 77 | 78 | The home page of the app accepts a `msg` querystring argument containing 79 | a hex-encoded status message to display. This is used, for instance, when 80 | users fail to authenticate properly and the server needs to provide feedback. 81 | 82 | This isn't exactly a best practice for various reasons, but most importantly, 83 | it contains a [Reflected Cross Site Scripting][reflected] vulnerability! 84 | 85 | Read about the vulnerability, and then try crafting a URL that, when visited, 86 | causes a logged-in user's browser to display an alert dialog that contains 87 | their session cookie (accessible through `document.cookie`). 88 | 89 | #### Stopping Cookie Theft 90 | 91 | Cookie theft is a particularly big danger because it allows attackers to 92 | do anything on a user's behalf, whenever they want. So first, mitigate 93 | the effects of XSS vulnerabilities by modifying `sessionCookie.serialize()` 94 | in `app.js` to issue [HttpOnly][] cookies. 95 | 96 | Manually test your solution by loading your specially crafted URL from 97 | the previous section; you shouldn't see the session cookie in that 98 | alert dialog anymore (you will have to log out and log back in for the 99 | HttpOnly cookie to be set properly). 100 | 101 | Run `bin/verify.js httponly` to verify that your solution works. 102 | 103 | 104 | 105 | #### Defining a Content Security Policy 106 | 107 | It's nice that the damage that can be done via the XSS attack is somewhat 108 | mitigated, but it's way better to prevent the attack entirely! 109 | 110 | The [Content Security Policy][csp] specification is one of the most 111 | awesome security innovations to come to browsers in recent years. It 112 | allows servers to change the default allowances for what kinds of 113 | script can be executed, and even what kinds of embedded resources 114 | (such as iframes, images, and style sheets) can be included in a page. This 115 | is in accordance with the [Principle of Least Authority][pola], which 116 | is a good best practice for any secure system. 117 | 118 | Since our app doesn't actually have *any* client-side script or embedded 119 | content, we can enforce the most restrictive CSP possible by setting the 120 | `Content-Security-Policy` header to `default-src 'none'`. 121 | 122 | Once you've done this, load your specially crafted URL again; you shouldn't 123 | even see an alert dialog, and your browser's debugging console might 124 | even explain why your JS wasn't executed. 125 | 126 | Run `bin/verify.js csp` to verify that your solution works. 127 | 128 | 129 | 130 | #### Stopping XSS with HTML Escaping 131 | 132 | CSP is only available on the most modern browsers, and we need to 133 | protect users on older ones too. Besides that, of course, we actually want 134 | to display the message content in a correct and useful way. 135 | 136 | This can be done by properly escaping the untrusted input coming in 137 | from the `msg` querystring argument. 138 | 139 | The [OWASP XSS Prevention Cheat Sheet][xss-cheat-sheet] is indispensable 140 | here. Check it out and use a reliable function like underscore's 141 | [_.escape][] to escape the `msg` argument before inserting it into your 142 | HTML. (Note that if you decide to use underscore, you'll want to install it 143 | first using `npm install underscore`.) 144 | 145 | Run `bin/verify.js html-escaping` to verify that your solution works. 146 | 147 | 148 | 149 | ### Vulnerability: Cross-Site Request Forgery 150 | 151 | Cookies are a form of [ambient authority][], which means that they get 152 | sent with *every* request to a website--even when that request comes from 153 | a different website! 154 | 155 | Consider a website called killyournotes.com which contains the following 156 | form: 157 | 158 | ```html 159 | 160 |
161 | 162 |
163 | 164 | ``` 165 | 166 | Every user logged in to your application would immediately have their notes 167 | deleted whenever they visited killyournotes.com! 168 | 169 | Try doing this now: copy the above text and paste it into an HTML file 170 | anywhere. Then visit the file in your browser and see what happens. 171 | 172 | This is called a [Cross-Site Request Forgery][csrf] (CSRF) because it 173 | involves another site "forging" a request to your application and taking 174 | advantage of the ambient authority provided by cookies. In security 175 | parlance, your application has unwittingly become a [confused deputy][]. 176 | 177 | This exploit can be protected against by requiring that every incoming request 178 | that changes your application's state (e.g. a POST request) also come with 179 | an explicit token guaranteeing that the request indeed came from a page 180 | on your site, and not someone else's. 181 | 182 | To complete this mission, you'll need to do a number of things: 183 | 184 | * When a GET request arrives at your application, check to see if the 185 | session has a value called `csrfToken`. If it doesn't, create one using 186 | [crypto.randomBytes()][] and set the session cookie. 187 | 188 | * Whenever your site displays a form, add a hidden input with the name 189 | `csrfToken` to the form, and set its value to that of `session.csrfToken`. 190 | 191 | * Whenever your site processes a POST request, ensure that the incoming form 192 | data has a value for `csrfToken` that matches that of `session.csrfToken`. 193 | If it doesn't, return a 403 (forbidden) reponse code. 194 | 195 | Once you've done this, your exploit should result in a 403 instead of 196 | deleting the current user's notes, and your application should still retain 197 | all existing functionality. 198 | 199 | Run `bin/verify.js csrf` to verify that your solution works. 200 | 201 | 202 | 203 | ### Hooray! 204 | 205 | You've completed all the challenges so far. You can verify that your `app.js` 206 | protects against all the problems you've solved, and still retains its 207 | basic functionality, by running `bin/verify.js all`. 208 | 209 | If you want to learn more about Web security, you should read Michal Zalewski's 210 | [The Tangled Web][tangled]. It is hilarious and very educational. 211 | 212 | ## Goals and Future Plans 213 | 214 | `app-vulnerable.js` intentionally contains a number of [OWASP][]-defined 215 | security vulnerabilities that aren't currently part of the quest, such as: 216 | 217 | * [Sensitive Data Exposure][sde] for password storage 218 | * [Insecure Direct Object References][idor] / 219 | [Broken Authentication and Session Management][brokenauth] for session keys 220 | 221 | Learners should first exploit these vulnerabilities, so they 222 | understand how they work, and then modify the code to implement 223 | defenses against them. 224 | 225 | Ideally, the tutorial will also teach users about more recent innovations in 226 | browser security, such as [HTTP Strict Transport Security][hsts]. It should 227 | also teach developers how to use security tools like the 228 | [Zed Attack Proxy][zap] to easily detect for vulnerabilities in their 229 | own applications. 230 | 231 | By the end of the tutorial, users will have familiarized themselves with a 232 | variety of types of attacks. The will also have familiarized themselves with 233 | the OWASP website and will be equipped to independently learn about security 234 | in the future. 235 | 236 | [confused deputy]: http://en.wikipedia.org/wiki/Confused_deputy_problem 237 | [crypto.randomBytes()]: http://nodejs.org/api/crypto.html#crypto_crypto_randombytes_size_callback 238 | [ambient authority]: http://en.wikipedia.org/wiki/Ambient_authority 239 | [pola]: http://en.wikipedia.org/wiki/Principle_of_least_privilege 240 | [xss-cheat-sheet]: https://www.owasp.org/index.php/XSS_%28Cross_Site_Scripting%29_Prevention_Cheat_Sheet 241 | [_.escape]: http://underscorejs.org/#escape 242 | [zap]: https://www.owasp.org/index.php/OWASP_Zed_Attack_Proxy_Project 243 | [HttpOnly]: https://www.owasp.org/index.php/HttpOnly 244 | [phantomjs]: http://phantomjs.org/ 245 | [workshopper]: https://github.com/rvagg/workshopper 246 | [learnyounode]: https://github.com/rvagg/learnyounode 247 | [levelmeup]: https://github.com/rvagg/levelmeup 248 | [OWASP]: https://www.owasp.org/ 249 | [csrf]: https://www.owasp.org/index.php/Cross-Site_Request_Forgery_%28CSRF%29 250 | [reflected]: https://www.owasp.org/index.php/Testing_for_Reflected_Cross_site_scripting_%28OWASP-DV-001%29 251 | [sde]: https://www.owasp.org/index.php/Top_10_2013-A6-Sensitive_Data_Exposure 252 | [idor]: https://www.owasp.org/index.php/Top_10_2013-A4-Insecure_Direct_Object_References 253 | [brokenauth]: https://www.owasp.org/index.php/Top_10_2013-A2-Broken_Authentication_and_Session_Management 254 | [csp]: https://developer.mozilla.org/en-US/docs/Security/CSP/Introducing_Content_Security_Policy 255 | [hsts]: https://developer.mozilla.org/en-US/docs/Security/HTTP_Strict_Transport_Security 256 | [tangled]: http://lcamtuf.coredump.cx/tangled/ 257 | [redos]: https://www.owasp.org/index.php/Regular_expression_Denial_of_Service_-_ReDoS 258 | -------------------------------------------------------------------------------- /app-vulnerable.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | var url = require('url'); 3 | var querystring = require('querystring'); 4 | var level = require('levelup'); 5 | 6 | var PORT = process.env.PORT || 3000; 7 | var VALID_USERNAME = /^([A-Za-z0-9_]+)+$/; 8 | 9 | // These view functions all return strings of HTML. 10 | var views = { 11 | 401: function() { return 'You must log in first.'; }, 12 | 404: function() { return "Alas, this is a 404."; }, 13 | login: function(req) { return [ 14 | '
', 15 | ' username: ', 16 | ' password: ', 17 | ' ', 18 | ' ', 20 | '
' 21 | ].join('\n'); }, 22 | notes: function(req, notes) { return [ 23 | '
', 24 | ' ', 25 | '
', 26 | '
', 27 | ' ', 28 | ' ', 29 | '
' 30 | ].join('\n'); } 31 | }; 32 | 33 | var passwordStorage = { 34 | check: function(db, user, pass, cb) { 35 | db.get('password-' + user, function(err, v) { 36 | err ? (err.notFound ? cb(null, false) : cb(err)) : cb(err, v == pass); 37 | }); 38 | }, 39 | create: function(db, user, pass, cb) { 40 | db.get('password-' + user, function(err) { 41 | if (!err) return cb(new Error('exists')); 42 | err.notFound ? db.put('password-' + user, pass, cb) : cb(err); 43 | }); 44 | } 45 | }; 46 | 47 | var sessionCookie = { 48 | parse: function(cookie) { 49 | try { 50 | var match = cookie.match(/session=([A-Za-z0-9+\/]+)/); 51 | return JSON.parse(Buffer(match[1], 'base64')); 52 | } catch (e) {} 53 | }, 54 | serialize: function(session) { 55 | return 'session=' + Buffer(JSON.stringify(session)).toString('base64'); 56 | }, 57 | clear: 'session=; Expires=Thu, 01 Jan 1970 00:00:00 GMT' 58 | }; 59 | 60 | var routes = { 61 | 'GET /': function showLoginFormOrUserNotes(req, res) { 62 | if (req.query.msg) 63 | res.write('
' + Buffer(req.query.msg, 'hex') + '
\n'); 64 | if (!req.session.user) 65 | return res.end(views.login(req)); 66 | app.db.get('notes-' + req.session.user, function(err, value) { 67 | res.end(views.notes(req, err ? '' : value)); 68 | }); 69 | }, 70 | 'POST /': function updateUserNotes(req, res, next) { 71 | if (!req.session.user) return next(401); 72 | var notes = req.body.notes || ''; 73 | app.db.put('notes-' + req.session.user, notes, function(err) { 74 | if (err) return next(err); 75 | 76 | return res.redirect("/", "Your notes were saved at " + Date() + "."); 77 | }); 78 | }, 79 | 'POST /login': function authenticateAndLoginUser(req, res, next) { 80 | var username = req.body.username; 81 | var password = req.body.password; 82 | var createSession = function createSession() { 83 | req.session.user = username; 84 | res.setHeader("Set-Cookie", sessionCookie.serialize(req.session)); 85 | return res.redirect("/"); 86 | }; 87 | 88 | if (!VALID_USERNAME.test(username)) 89 | return res.redirect("/", 'Invalid username ' + 90 | '(only A-Z, 0-9, and _ are allowed).'); 91 | if (!password) return res.redirect("/", 'Please provide a password.'); 92 | 93 | if (req.body.action == 'register') 94 | passwordStorage.create(app.db, username, password, function(err) { 95 | if (!err) return createSession(); 96 | if (!/exists/.test(err)) return next(err); 97 | res.redirect("/", 'That user already exists.'); 98 | }); 99 | else 100 | passwordStorage.check(app.db, username, password, function(err, ok) { 101 | if (err) return next(err); else if (ok) return createSession(); 102 | res.redirect("/", 'Invalid username or password.'); 103 | }); 104 | }, 105 | 'POST /logout': function logoutUser(req, res) { 106 | res.setHeader("Set-Cookie", sessionCookie.clear); 107 | return res.redirect("/"); 108 | } 109 | }; 110 | 111 | var app = function(req, res) { 112 | req.urlInfo = url.parse(req.url, true); 113 | req.query = req.urlInfo.query; 114 | req.session = sessionCookie.parse(req.headers['cookie']) || {}; 115 | req.body = {}; 116 | 117 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 118 | res.statusCode = 200; 119 | 120 | var routeName = req.method + ' ' + req.urlInfo.pathname; 121 | var route = routes[routeName]; 122 | var next = function next(err) { 123 | if (typeof(err) == 'number') { 124 | res.statusCode = err; 125 | return res.end(views[err] ? views[err](req) : err.toString()); 126 | } 127 | console.error(err.stack || err); 128 | res.statusCode = 500; 129 | res.end("Sorry, something exploded."); 130 | }; 131 | 132 | if (!route) return next(404); 133 | 134 | res.redirect = function(where, msg) { 135 | if (msg) where += "?msg=" + Buffer(msg).toString('hex'); 136 | res.setHeader("Location", where); 137 | res.statusCode = 303; 138 | res.end(); 139 | }; 140 | 141 | if (req.method == 'POST') { 142 | var bodyChunks = []; 143 | 144 | req.on('data', bodyChunks.push.bind(bodyChunks)); 145 | req.on('end', function() { 146 | var data = Buffer.concat(bodyChunks).toString(); 147 | req.body = querystring.parse(data); 148 | route(req, res, next); 149 | }); 150 | } else route(req, res, next); 151 | }; 152 | 153 | module.exports = app; 154 | module.exports.sessionCookie = sessionCookie; 155 | module.exports.passwordStorage = passwordStorage; 156 | 157 | if (!module.parent) { 158 | var server = http.createServer(app); 159 | app.db = level(__dirname + '/db/'); 160 | server.listen(PORT, function() { 161 | console.log("listening on port " + PORT); 162 | }); 163 | } 164 | -------------------------------------------------------------------------------- /bin/security-adventure.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | var verify = require('./verify'); 6 | var Workshopper = require('../lib/workshopper'); 7 | var readmeSections = require('../lib/readme-sections'); 8 | 9 | var PROBLEMS = verify.PROBLEMS; 10 | 11 | function problemIdFromName(name) { 12 | for (var id in PROBLEMS) 13 | if (PROBLEMS[id] == name) return id; 14 | throw new Error("unknown problem name: " + name); 15 | } 16 | 17 | Workshopper({ 18 | name: 'security-adventure', 19 | title: 'Security Adventure!', 20 | appDir: path.normalize(path.join(__dirname, '..')), 21 | problems: function() { 22 | return Object.keys(PROBLEMS).map(function(key) { 23 | return PROBLEMS[key]; 24 | }); 25 | }, 26 | preMenu: function(cb) { 27 | if (fs.existsSync('app.js')) return cb(); 28 | var copyCmd = process.platform == 'win32' ? 'copy' : 'cp'; 29 | var appVuln = path.normalize(path.join(__dirname, '..', 30 | 'app-vulnerable.js')); 31 | console.log("Please run the following:\n"); 32 | console.log(" " + copyCmd + " " + appVuln + " app.js"); 33 | console.log(" npm install levelup leveldown"); 34 | console.log(readmeSections.app); 35 | console.log('When you are ready to begin the adventure, run ' + 36 | this.name + ' again.\n'); 37 | process.exit(1); 38 | }, 39 | runVerifier: function(name, filename, successCb) { 40 | process.env.APP_MODULE = filename; 41 | verify(problemIdFromName(name), function(exitCode) { 42 | if (!exitCode) return successCb(); 43 | process.exit(exitCode); 44 | }); 45 | }, 46 | showHelp: function() { 47 | console.log(readmeSections.help); 48 | }, 49 | showProblem: function(name) { 50 | console.log(readmeSections[problemIdFromName(name)]); 51 | } 52 | }).init(); 53 | -------------------------------------------------------------------------------- /bin/verify.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | var spawn = require('child_process').spawn; 6 | var bold = require('../lib/term-util').bold; 7 | 8 | var TEST_PROBLEM_ONLY = 'TEST_PROBLEM_ONLY' in process.env; 9 | var TAP_PRETTIFY = path.normalize(path.join(__dirname, '..', 'node_modules', 10 | 'tap-prettify', 'bin', 11 | 'tap-prettify.js')); 12 | var PROBLEMS = { 13 | 'redos': 'RegExp Denial of Service', 14 | 'httponly': 'HttpOnly Cookie', 15 | 'csp': 'Content Security Policy', 16 | 'html-escaping': 'HTML Escaping', 17 | 'csrf': 'Cross-Site Request Forgery' 18 | }; 19 | 20 | function help() { 21 | console.log("Usage: verify.js \n"); 22 | console.log("Valid problems:\n"); 23 | Object.keys(PROBLEMS).forEach(function(name) { 24 | console.log(" " + name + " - " + PROBLEMS[name]); 25 | }); 26 | console.log(" all - Verify all of the above\n"); 27 | } 28 | 29 | function verifyMain(problem, cb) { 30 | if (!(problem in PROBLEMS) && problem != 'all') { 31 | help(); 32 | return cb(1); 33 | } 34 | 35 | var testDir = path.normalize(path.join(__dirname, '..', 'test')); 36 | var baseTests = fs.readdirSync(testDir) 37 | .filter(function(f) { return /\.js$/.test(f); }) 38 | .map(function(f) { return path.join(testDir, f); }); 39 | var problemTests = ((problem == 'all') ? Object.keys(PROBLEMS) : [problem]) 40 | .map(function(p) { return path.join(testDir, 'problems', p + '.js'); }); 41 | var allTests = problemTests.concat(TEST_PROBLEM_ONLY ? [] : baseTests); 42 | var problemName = (problem == 'all' 43 | ? bold('all the problems') 44 | : 'the ' + bold(PROBLEMS[problem]) + ' problem'); 45 | 46 | console.log("Now ensuring your app retains existing functionality while " + 47 | "solving\n" + problemName + "...\n"); 48 | 49 | var child = spawn(process.execPath, 50 | [TAP_PRETTIFY, '--stderr'].concat(allTests)); 51 | 52 | child.stdout.pipe(process.stdout); 53 | child.stderr.pipe(process.stderr); 54 | child.on('exit', function(code) { 55 | console.log(); 56 | if (code == 0) { 57 | console.log("Congratulations! Your app has solved " + 58 | problemName + " while\n" + 59 | "retaining existing functionality.\n"); 60 | } else { 61 | console.log("Alas, your app has not solved " + problemName + 62 | " while\nretaining existing functionality.\n"); 63 | } 64 | cb(code); 65 | }); 66 | } 67 | 68 | module.exports = verifyMain; 69 | module.exports.PROBLEMS = PROBLEMS; 70 | 71 | if (!module.parent) verifyMain(process.argv[2], process.exit.bind(process)); 72 | -------------------------------------------------------------------------------- /lib/menu.js: -------------------------------------------------------------------------------- 1 | const tmenu = require('terminal-menu') 2 | , path = require('path') 3 | , fs = require('fs') 4 | , xtend = require('xtend') 5 | , EventEmitter = require('events').EventEmitter 6 | 7 | const repeat = require('./term-util').repeat 8 | , bold = require('./term-util').bold 9 | , italic = require('./term-util').italic 10 | 11 | function showMenu (opts) { 12 | var emitter = new EventEmitter() 13 | , menu = tmenu(xtend({ 14 | width : opts.width 15 | , x : 3 16 | , y : 2 17 | }, opts.menu)) 18 | 19 | menu.reset() 20 | menu.write(bold(opts.title) + '\n') 21 | if (typeof opts.subtitle == 'string') 22 | menu.write(italic(opts.subtitle) + '\n') 23 | menu.write(repeat('-', opts.width) + '\n') 24 | 25 | opts.problems.forEach(function (name) { 26 | var isDone = opts.completed.indexOf(name) >= 0 27 | , m = '[COMPLETED]' 28 | 29 | name = name 30 | 31 | if (isDone) 32 | return menu.add(bold('»') + ' ' + name + Array(63 - m.length - name.length + 1).join(' ') + m) 33 | else 34 | menu.add(bold('»') + ' ' + name) 35 | }) 36 | 37 | menu.write(repeat('-', opts.width) + '\n') 38 | menu.add(bold('HELP')) 39 | menu.add(bold('EXIT')) 40 | 41 | menu.on('select', function (label) { 42 | var name = label.replace(/(^[^»]+»[^\s]+ )|(\s{2}.*)/g, '') 43 | 44 | menu.close() 45 | 46 | if (name === bold('EXIT')) 47 | return emitter.emit('exit') 48 | 49 | if (name === bold('HELP')) 50 | return emitter.emit('help') 51 | 52 | emitter.emit('select', name) 53 | }) 54 | 55 | menu.createStream().pipe(process.stdout) 56 | 57 | return emitter 58 | } 59 | 60 | module.exports = showMenu 61 | -------------------------------------------------------------------------------- /lib/print-text.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | , path = require('path') 3 | , colorsTmpl = require('colors-tmpl') 4 | 5 | function printText (name, appDir, file, callback) { 6 | var variables = { 7 | appname : name 8 | , rootdir : appDir 9 | } 10 | 11 | fs.readFile(file, 'utf8', function (err, contents) { 12 | if (err) 13 | throw err 14 | 15 | contents = contents.toString() 16 | contents = colorsTmpl(contents) 17 | Object.keys(variables).forEach(function (k) { 18 | contents = contents.replace(new RegExp('\\{' + k + '\\}', 'gi'), variables[k]) 19 | }) 20 | // proper path resolution 21 | contents = contents.replace(/\{rootdir:([^}]+)\}/gi, function (match, subpath) { 22 | return path.join(appDir, subpath) 23 | }) 24 | console.log(contents) 25 | callback && callback() 26 | }) 27 | } 28 | 29 | module.exports = printText 30 | -------------------------------------------------------------------------------- /lib/readme-sections.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | 4 | var SECTION_MARKER = /^\<\!-- section: ([a-z\-]+) --\>/; 5 | 6 | var filename = path.normalize(path.join(__dirname, '..', 'README.md')); 7 | var readme = fs.readFileSync(filename, 'utf8'); 8 | var sections = {prologue: []}; 9 | var currentSection = 'prologue'; 10 | 11 | readme.split('\n').forEach(function(line) { 12 | var match = line.match(SECTION_MARKER); 13 | if (match) { 14 | sections[currentSection] = sections[currentSection].join('\n'); 15 | currentSection = match[1]; 16 | sections[currentSection] = []; 17 | } else { 18 | if (/Run `bin\/verify.js (.+)` to verify that your solution works/.test(line)) 19 | return; 20 | sections[currentSection].push(line); 21 | } 22 | }); 23 | 24 | sections[currentSection] = sections[currentSection].join('\n'); 25 | 26 | module.exports = sections; 27 | -------------------------------------------------------------------------------- /lib/term-util.js: -------------------------------------------------------------------------------- 1 | function repeat (ch, sz) { 2 | return new Array(sz + 1).join(ch) 3 | } 4 | 5 | function wrap (s_, n) { 6 | var s = String(s_) 7 | return s + repeat(' ', Math.max(0, n + 1 - s.length)) 8 | } 9 | 10 | function center (width, s) { 11 | var n = (width - s.length) / 2 12 | return ' ##' 13 | + repeat(' ', Math.floor(n)) 14 | + yellow(bold(s)) 15 | + repeat(' ', Math.ceil(n)) 16 | + '##' 17 | } 18 | 19 | function cfn (sc, ec) { 20 | return function (s) { 21 | return sc + s + ec 22 | } 23 | } 24 | 25 | var bold = cfn('\x1B[1m', '\x1B[22m') 26 | , italic = cfn('\x1B[3m', '\x1B[23m') 27 | , red = cfn('\x1B[31m', '\x1B[39m') 28 | , green = cfn('\x1B[32m', '\x1B[39m') 29 | , yellow = cfn('\x1B[33m', '\x1B[39m') 30 | , blue = cfn('\x1B[34m', '\x1B[39m') 31 | 32 | module.exports = { 33 | repeat : repeat 34 | , wrap : wrap 35 | , center : center 36 | , bold : bold 37 | , italic : italic 38 | 39 | , red : red 40 | , green : green 41 | , yellow : yellow 42 | , blue : blue 43 | } -------------------------------------------------------------------------------- /lib/usage.txt: -------------------------------------------------------------------------------- 1 | {bold}{yellow}Usage{/yellow}{/bold} 2 | 3 | {bold}{green}{appname}{/green}{/bold} 4 | Show a menu to interactively select a problem. 5 | {bold}{green}{appname}{/green} list{/bold} 6 | Show a newline-separated list of all the problems. 7 | {bold}{green}{appname}{/green} select NAME{/bold} 8 | Select a problem. 9 | {bold}{green}{appname}{/green} current{/bold} 10 | Show the currently selected problem. 11 | {bold}{green}{appname}{/green} verify program.js{/bold} 12 | Verify your program solves the current problem. 13 | -------------------------------------------------------------------------------- /lib/workshopper.js: -------------------------------------------------------------------------------- 1 | const argv = require('optimist').argv 2 | , fs = require('fs') 3 | , path = require('path') 4 | , mkdirp = require('mkdirp') 5 | 6 | const showMenu = require('./menu') 7 | , printText = require('./print-text') 8 | , repeat = require('./term-util').repeat 9 | , bold = require('./term-util').bold 10 | , red = require('./term-util').red 11 | , green = require('./term-util').green 12 | , yellow = require('./term-util').yellow 13 | , center = require('./term-util').center 14 | 15 | const defaultWidth = 65 16 | 17 | function Workshopper (options) { 18 | if (!(this instanceof Workshopper)) 19 | return new Workshopper(options) 20 | 21 | if (typeof options != 'object') 22 | throw new TypeError('need to provide an options object') 23 | 24 | if (typeof options.name != 'string') 25 | throw new TypeError('need to provide a `name` String option') 26 | 27 | if (typeof options.title != 'string') 28 | throw new TypeError('need to provide a `title` String option') 29 | 30 | if (typeof options.appDir != 'string') 31 | throw new TypeError('need to provide an `appDir` String option') 32 | 33 | this.name = options.name 34 | this.title = options.title 35 | this.subtitle = options.subtitle 36 | this.menuOptions = options.menu 37 | this.width = typeof options.width == 'number' ? options.width : defaultWidth 38 | 39 | this.problems = options.problems 40 | this.showProblem = options.showProblem 41 | this.showHelp = options.showHelp 42 | this.preMenu = options.preMenu 43 | this.runVerifier = options.runVerifier 44 | this.appDir = options.appDir 45 | this.dataDir = path.join( 46 | process.env.HOME || process.env.USERPROFILE 47 | , '.config' 48 | , this.name 49 | ) 50 | 51 | mkdirp.sync(this.dataDir) 52 | } 53 | 54 | Workshopper.prototype.init = function () { 55 | if (argv.h || argv.help || argv._[0] == 'help') 56 | return this._printHelp() 57 | 58 | if (argv.v || argv.version || argv._[0] == 'version') 59 | return console.log(this.name + '@' + require(path.join(this.appDir, 'package.json')).version) 60 | 61 | if (argv._[0] == 'list') { 62 | return this.problems().forEach(function (name) { 63 | console.log(name) 64 | }) 65 | } 66 | 67 | if (argv._[0] == 'current') 68 | return console.log(this.getData('current')) 69 | 70 | if (argv._[0] == 'select' || argv._[0] == 'print') { 71 | return onselect.call(this, argv._.length > 1 72 | ? argv._.slice(1).join(' ') 73 | : this.getData('current') 74 | ) 75 | } 76 | 77 | if (argv._[0] == 'verify') 78 | return this.verify() 79 | 80 | if (this.preMenu) return this.preMenu(this.printMenu.bind(this)); 81 | 82 | this.printMenu() 83 | } 84 | 85 | Workshopper.prototype.verify = function (run) { 86 | var current = this.getData('current') 87 | var filename = path.resolve(process.cwd(), argv._[1]); 88 | 89 | if (!current) { 90 | console.error('ERROR: No active problem. Select a challenge from the menu.') 91 | return process.exit(1) 92 | } 93 | 94 | this.runVerifier(current, filename, function onSuccess() { 95 | this.updateData('completed', function (xs) { 96 | if (!xs) xs = [] 97 | var ix = xs.indexOf(current) 98 | return ix >= 0 ? xs : xs.concat(current) 99 | }) 100 | 101 | completed = this.getData('completed') || [] 102 | 103 | remaining = this.problems().length - completed.length 104 | if (remaining === 0) { 105 | console.log('You\'ve finished all the challenges! Hooray!\n') 106 | } else { 107 | console.log( 108 | 'You have ' 109 | + remaining 110 | + ' challenge' 111 | + (remaining != 1 ? 's' : '') 112 | + ' left.' 113 | ) 114 | console.log('Type `' + this.name + '` to show the menu.\n') 115 | } 116 | }.bind(this)); 117 | } 118 | 119 | Workshopper.prototype.printMenu = function () { 120 | var menu = showMenu({ 121 | name : this.name 122 | , title : this.title 123 | , subtitle : this.subtitle 124 | , width : this.width 125 | , completed : this.getData('completed') || [] 126 | , problems : this.problems() 127 | , menu : this.menuOptions 128 | }) 129 | menu.on('select', onselect.bind(this)) 130 | menu.on('exit', function () { 131 | console.log() 132 | process.exit(0) 133 | }) 134 | menu.on('help', function () { 135 | console.log() 136 | return this._printHelp() 137 | }.bind(this)) 138 | } 139 | 140 | Workshopper.prototype.getData = function (name) { 141 | var file = path.resolve(this.dataDir, name + '.json') 142 | try { 143 | return JSON.parse(fs.readFileSync(file, 'utf8')) 144 | } catch (e) {} 145 | return null 146 | } 147 | 148 | Workshopper.prototype.updateData = function (name, fn) { 149 | var json = {} 150 | , file 151 | 152 | try { 153 | json = this.getData(name) 154 | } catch (e) {} 155 | 156 | file = path.resolve(this.dataDir, name + '.json') 157 | fs.writeFileSync(file, JSON.stringify(fn(json))) 158 | } 159 | 160 | Workshopper.prototype._printHelp = function () { 161 | this._printUsage() 162 | 163 | if (this.showHelp) 164 | this.showHelp(); 165 | } 166 | 167 | Workshopper.prototype._printUsage = function () { 168 | printText(this.name, this.appDir, path.join(__dirname, './usage.txt')) 169 | } 170 | 171 | function onselect (name) { 172 | this.updateData('current', function () { 173 | return name 174 | }) 175 | 176 | this.showProblem(name); 177 | 178 | console.log( 179 | bold('\n » To print these instructions again, run: `' + this.name + ' print`.')) 180 | console.log( 181 | bold(' » To verify your program, run: `' + this.name + ' verify app.js`.')) 182 | if (this.showHelp) { 183 | console.log( 184 | bold(' » For help with this problem or with ' + this.name + ', run:\n `' + this.name + ' help`.')) 185 | } 186 | console.log() 187 | } 188 | 189 | module.exports = Workshopper 190 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "security-adventure", 3 | "version": "0.0.2", 4 | "description": "Go on an educational Web security adventure!", 5 | "main": "app.js", 6 | "bin": { 7 | "security-adventure": "bin/security-adventure.js" 8 | }, 9 | "dependencies": { 10 | "mkdirp": "0.3.5", 11 | "colors-tmpl": "0.1.0", 12 | "optimist": "0.6.0", 13 | "xtend": "2.1.1", 14 | "terminal-menu": "0.1.0", 15 | "levelup": "0.16.0", 16 | "leveldown": "0.8.2", 17 | "memdown": "0.3.0", 18 | "request": "2.27.0", 19 | "fibers": "1.0.1", 20 | "phantom-wd-runner": "0.0.2", 21 | "wd": "0.1.4", 22 | "cheerio": "0.12.2", 23 | "tap-prettify": "0.0.2" 24 | }, 25 | "scripts": { 26 | "test": "node node_modules/tap-prettify/bin/tap-prettify.js test/*.js test/solutions/*.js --stderr --timeout 60" 27 | }, 28 | "keywords": [ 29 | "security", 30 | "educational", 31 | "guide", 32 | "tutorial", 33 | "learn", 34 | "workshop" 35 | ], 36 | "author": { 37 | "name": "Atul Varma", 38 | "email": "varmaa@gmail.com", 39 | "url": "http://toolness.com" 40 | }, 41 | "repository": { 42 | "type": "git", 43 | "url": "git://github.com/toolness/security-adventure.git" 44 | }, 45 | "license": "BSD-2-Clause" 46 | } 47 | -------------------------------------------------------------------------------- /test/lib/app-request.js: -------------------------------------------------------------------------------- 1 | var request = require('request'); 2 | 3 | var util = require('./util'); 4 | var app = util.getApp(); 5 | 6 | module.exports = function appRequest(options, cb) { 7 | var jar = request.jar(); 8 | var session = {}; 9 | 10 | if (typeof(options) == 'string') options = {url: options}; 11 | 12 | if (!('followAllRedirects' in options)) 13 | options.followAllRedirects = true; 14 | 15 | if (options.method == 'POST' && !options.ignoreCsrfToken) { 16 | session.csrfToken = "TESTING"; 17 | if (!options.form) options.form = {}; 18 | options.form.csrfToken = session.csrfToken; 19 | } 20 | delete options.ignoreCsrfToken; 21 | 22 | if ('user' in options) { 23 | session.user = options.user; 24 | delete options.user; 25 | } 26 | 27 | if (Object.keys(session).length) 28 | jar.add(request.cookie(app.sessionCookie.serialize(session))); 29 | 30 | options.jar = jar; 31 | app.db = options.db || util.level(); 32 | delete options.db; 33 | util.serve(app, function(server) { 34 | options.url = server.baseURL + options.url; 35 | request(options, function(err, res, body) { 36 | server.close(); 37 | cb(err, res, body, app.db); 38 | }); 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /test/lib/fiber-level.js: -------------------------------------------------------------------------------- 1 | var Fiber = require('fibers'); 2 | 3 | module.exports = function FiberLevelObject(db) { 4 | var self = this; 5 | 6 | self.get = function(key) { 7 | var errorToThrow = new Error('get failed for key: ' + key); 8 | var fiber = Fiber.current; 9 | 10 | db.get(key, function(err, value) { 11 | if (err) { 12 | errorToThrow.originalError = err; 13 | errorToThrow.message += " (" + err.message + ")"; 14 | return fiber.throwInto(errorToThrow); 15 | } 16 | 17 | return fiber.run(value); 18 | }); 19 | return Fiber.yield(); 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /test/lib/fiber-webdriver.js: -------------------------------------------------------------------------------- 1 | // This makes it easy to use Webdriver via a synchronous API in node, by 2 | // running it in a Fiber. The Webdriver API was originally intended to 3 | // be used in a synchronous way, fortunately, so converting async 4 | // Webdriver calls to synchronous ones is easy. 5 | // 6 | // Here's a simple asynchronous webdriver example: 7 | // 8 | // var browser = require('wd').remote(); 9 | // browser.init(function(err, sessionID) { 10 | // browser.get("http://foo.org/", function(err) { 11 | // /* ... */ 12 | // }); 13 | // }); 14 | // 15 | // Here's the synchronous version: 16 | // 17 | // Fiber(function() { 18 | // var browser = FiberWebdriverObject(require('wd').remote()); 19 | // var sessionID = browser.init(); 20 | // browser.get("http://foo.org"); 21 | // /* ... */ 22 | // }).run(); 23 | // 24 | // Keep in mind that the synchronous version isn't *actually* blocking 25 | // the entire process/thread, it's just running in a Fiber. 26 | 27 | var Fiber = require('fibers'); 28 | var elementConstructor = require('wd/lib/element').element; 29 | 30 | function wrapObject(obj) { 31 | if (Array.isArray(obj)) 32 | return obj.map(wrapObject); 33 | if (obj instanceof elementConstructor) 34 | return new FiberWebdriverObject(obj); 35 | return obj; 36 | } 37 | 38 | function unwrapObject(obj) { 39 | if (Array.isArray(obj)) 40 | return obj.map(unwrapObject); 41 | if (obj instanceof FiberWebdriverObject) 42 | return obj._asyncWebdriverObject; 43 | return obj; 44 | } 45 | 46 | function FiberWebdriverObject(asyncWebdriverObject) { 47 | var self = this; 48 | var methodNames = Object.keys(Object.getPrototypeOf(asyncWebdriverObject)) 49 | .filter(function(name) { 50 | return (typeof(asyncWebdriverObject[name]) == "function" && 51 | name[0] != '_'); 52 | }); 53 | 54 | self._asyncWebdriverObject = asyncWebdriverObject; 55 | methodNames.forEach(function(name) { 56 | self[name] = function() { 57 | var method = asyncWebdriverObject[name]; 58 | var fiber = Fiber.current; 59 | var args = unwrapObject([].slice.call(arguments)); 60 | 61 | // If this fails, the traceback that led to this function call 62 | // will likely be more useful than the traceback of the exception 63 | // that's ultimately passed to us, so we'll generate an exception 64 | // now just in case one happens later. 65 | var errorToThrow = new Error(name + " failed"); 66 | 67 | args.push(function(err, result) { 68 | if (err) { 69 | errorToThrow.originalError = err; 70 | errorToThrow.message += " (" + err.message + ")"; 71 | return fiber.throwInto(errorToThrow); 72 | } 73 | 74 | return fiber.run(wrapObject(result)); 75 | }); 76 | method.apply(asyncWebdriverObject, args); 77 | return Fiber.yield(); 78 | }; 79 | }); 80 | } 81 | 82 | module.exports = FiberWebdriverObject; 83 | -------------------------------------------------------------------------------- /test/lib/index.js: -------------------------------------------------------------------------------- 1 | var util = require('./util'); 2 | 3 | exports.serve = util.serve; 4 | exports.level = util.level; 5 | exports.getApp = util.getApp; 6 | exports.wdtest = require('./wdtest'); 7 | exports.appRequest = require('./app-request'); 8 | -------------------------------------------------------------------------------- /test/lib/util.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var http = require('http'); 3 | var MemDOWN = require('memdown'); 4 | var levelup = require('levelup'); 5 | 6 | var ROOT_DIR = path.normalize(path.join(__dirname, '..', '..')); 7 | 8 | exports.level = function level() { 9 | return levelup('/', {db: function(loc) { return new MemDOWN(loc); }}); 10 | }; 11 | 12 | exports.serve = function serve(app, cb) { 13 | var server = http.createServer(app); 14 | server.listen(function() { 15 | server.baseURL = 'http://localhost:' + server.address().port; 16 | cb(server); 17 | }); 18 | return server; 19 | }; 20 | 21 | exports.getApp = function getApp() { 22 | var defaultModule = process.env.npm_lifecycle_event == "test" 23 | ? path.join(ROOT_DIR, 'app-vulnerable.js') 24 | : path.join(ROOT_DIR, 'app.js'); 25 | var APP_MODULE = path.resolve(process.cwd(), 26 | process.env.APP_MODULE || defaultModule); 27 | return require(APP_MODULE); 28 | }; 29 | -------------------------------------------------------------------------------- /test/lib/wdtest.js: -------------------------------------------------------------------------------- 1 | var test = require('tap-prettify').test; 2 | var url = require('url'); 3 | var Fiber = require('fibers'); 4 | var wd = require('wd'); 5 | var phantomWdRunner = require('phantom-wd-runner'); 6 | 7 | var FiberWebdriverObject = require('./fiber-webdriver'); 8 | var FiberLevelObject = require('./fiber-level'); 9 | var util = require('./util'); 10 | var app = util.getApp(); 11 | 12 | var WEBDRIVER_URL = process.env.WEBDRIVER_URL; 13 | 14 | var wdOptions = url.parse(WEBDRIVER_URL || "http://localhost:4444"); 15 | var phantom = null; 16 | 17 | function wdtest(name, cb) { 18 | test(name, function(t) { 19 | app.db = util.level(); 20 | util.serve(app, function(server) { 21 | var browser = wd.remote(wdOptions); 22 | var tSubclass = Object.create(t); 23 | 24 | tSubclass.end = function() {}; 25 | 26 | Fiber(function() { 27 | var fiberBrowser = new FiberWebdriverObject(browser); 28 | tSubclass.browser = Object.create(fiberBrowser); 29 | tSubclass.browser.$ = tSubclass.browser.elementByCssSelector; 30 | tSubclass.browser.get = function(path) { 31 | if (path[0] == '/') path = server.baseURL + path; 32 | return fiberBrowser.get(path); 33 | }; 34 | tSubclass.server = server; 35 | tSubclass.db = new FiberLevelObject(app.db); 36 | 37 | try { 38 | tSubclass.browser.init(); 39 | 40 | cb(tSubclass); 41 | } catch (e) { 42 | t.error(e); 43 | } 44 | 45 | browser.quit(); 46 | server.close(); 47 | t.end(); 48 | }).run(); 49 | }); 50 | }); 51 | } 52 | 53 | wdtest.setup = function setup() { 54 | if (WEBDRIVER_URL) return; 55 | test("setup phantom", function(t) { 56 | phantomWdRunner().on('listening', function() { 57 | phantom = this; 58 | t.end(); 59 | }); 60 | }); 61 | }; 62 | 63 | wdtest.teardown = function teardown() { 64 | if (WEBDRIVER_URL) return; 65 | test("teardown phantom", function(t) { 66 | phantom.kill(); 67 | t.end(); 68 | }); 69 | }; 70 | 71 | module.exports = wdtest; 72 | -------------------------------------------------------------------------------- /test/problems/csp.js: -------------------------------------------------------------------------------- 1 | var test = require('tap-prettify').test; 2 | 3 | var appRequest = require('../lib').appRequest; 4 | 5 | test("app defines a Content-Security-Policy header", function(t) { 6 | appRequest('/', function(err, res, body) { 7 | t.notOk(err); 8 | t.equal(res.headers['content-security-policy'], 9 | "default-src 'none'"); 10 | t.end(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/problems/csrf.js: -------------------------------------------------------------------------------- 1 | var cheerio = require('cheerio'); 2 | var test = require('tap-prettify').test; 3 | 4 | var testUtil = require('../lib'); 5 | var sessionCookie = testUtil.getApp().sessionCookie; 6 | var appRequest = testUtil.appRequest; 7 | 8 | // TODO: Ensure that the solution uses crypto.getRandomBytes(). 9 | 10 | test("GET / sets CSRF token in session cookie, login form", function(t) { 11 | appRequest('/', function(err, res, body) { 12 | t.notOk(err); 13 | var setCookieHeaders = res.headers['set-cookie']; 14 | t.ok(setCookieHeaders, "set-cookie header must be present"); 15 | if (!setCookieHeaders) return t.end(); 16 | t.equal(setCookieHeaders.length, 1, 17 | 'one set-cookie header is provided'); 18 | var session = sessionCookie.parse(setCookieHeaders[0]); 19 | t.ok(session, "session cookie exists"); 20 | t.ok(session && session.csrfToken, "session.csrfToken exists"); 21 | 22 | var $ = cheerio.load(body); 23 | t.equal($('input[name="csrfToken"]').attr("value"), 24 | session.csrfToken, 25 | ' should have expected value'); 26 | 27 | t.end(); 28 | }); 29 | }); 30 | 31 | function test403(path) { 32 | test("POST " + path + " without csrfToken returns 403", function(t) { 33 | appRequest({ 34 | method: 'POST', 35 | url: '/', 36 | ignoreCsrfToken: true 37 | }, function(err, res, body) { 38 | t.notOk(err); 39 | t.equal(res.statusCode, 403); 40 | t.end(); 41 | }); 42 | }); 43 | } 44 | 45 | test403("/"); 46 | test403("/login"); 47 | test403("/logout"); 48 | -------------------------------------------------------------------------------- /test/problems/html-escaping.js: -------------------------------------------------------------------------------- 1 | var wdtest = require('../lib').wdtest; 2 | 3 | wdtest.setup(); 4 | 5 | wdtest("app is not vulnerable to reflected XSS", function(t) { 6 | var exploit = "FAIL"; 7 | t.browser.get('/?msg=' + Buffer(exploit).toString('hex')); 8 | t.equal(t.browser.$("em").text(), exploit); 9 | }); 10 | 11 | wdtest.teardown(); 12 | -------------------------------------------------------------------------------- /test/problems/httponly.js: -------------------------------------------------------------------------------- 1 | var test = require('tap-prettify').test; 2 | 3 | var appRequest = require('../lib').appRequest; 4 | 5 | test("app sets HttpOnly cookies", function(t) { 6 | appRequest({ 7 | method: 'POST', 8 | url: '/login', 9 | followAllRedirects: false, 10 | form: { 11 | username: 'hello', 12 | password: 'meh', 13 | action: 'register' 14 | } 15 | }, function(err, res, body) { 16 | t.notOk(err); 17 | var cookie = res.headers['set-cookie']; 18 | t.ok(/HttpOnly/.test(cookie), "cookie should be HttpOnly: " + cookie); 19 | t.end(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/problems/redos.js: -------------------------------------------------------------------------------- 1 | var fork = require('child_process').fork; 2 | 3 | var test = require('tap-prettify').test; 4 | var appRequest = require('../lib').appRequest; 5 | 6 | var TIMEOUT = 5000; 7 | 8 | if (process.argv[2] == 'GO') { 9 | appRequest({ 10 | method: 'POST', 11 | url: '/login', 12 | form: { 13 | username: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!', 14 | password: 'meh', 15 | action: 'login' 16 | } 17 | }, function(err, res, body) { 18 | if (err) throw err; 19 | if (!/invalid username \(only/i.test(body)) 20 | throw new Error("unexpected body: " + body); 21 | process.exit(0); 22 | }); 23 | } else 24 | test("app is not vulnerable to regular expression DoS", function(t) { 25 | var child = fork('./redos', ['GO']); 26 | var timeout = setTimeout(function() { 27 | timeout = null; 28 | t.fail("timeout (" + TIMEOUT + "ms) exceeded"); 29 | child.kill(); 30 | t.end(); 31 | }, TIMEOUT); 32 | 33 | child.on('exit', function(code) { 34 | if (timeout === null) return; 35 | clearTimeout(timeout); 36 | t.equal(code, 0, "forked test should exit with code 0"); 37 | t.end(); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/solutions/csp.diff: -------------------------------------------------------------------------------- 1 | --- app-vulnerable.js 2013-09-19 13:18:56.000000000 +0200 2 | +++ app.js 2013-09-20 09:23:11.000000000 +0200 3 | @@ -100,6 +100,7 @@ 4 | req.body = {}; 5 | 6 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 7 | + res.setHeader('Content-Security-Policy', 'default-src \'none\''); 8 | res.statusCode = 200; 9 | 10 | var routeName = req.method + ' ' + req.urlInfo.pathname; 11 | -------------------------------------------------------------------------------- /test/solutions/csrf.diff: -------------------------------------------------------------------------------- 1 | --- app-vulnerable.js 2013-09-25 00:52:26.000000000 -0400 2 | +++ app.js 2013-09-25 01:04:14.000000000 -0400 3 | @@ -10,8 +10,12 @@ 4 | var views = { 5 | 401: function() { return 'You must log in first.'; }, 6 | 404: function() { return "Alas, this is a 404."; }, 7 | + _csrf: function(req) { 8 | + return ' '; 10 | + }, 11 | login: function(req) { return [ 12 | - '
', 13 | + '', this._csrf(req), 14 | ' username: ', 15 | ' password: ', 16 | ' ', 17 | @@ -20,10 +24,10 @@ 18 | '
' 19 | ].join('\n'); }, 20 | notes: function(req, notes) { return [ 21 | - '
', 22 | + '', this._csrf(req), 23 | ' ', 24 | '
', 25 | - '
', 26 | + '', this._csrf(req), 27 | ' ', 28 | ' ', 29 | '
' 30 | @@ -59,6 +63,11 @@ 31 | 32 | var routes = { 33 | 'GET /': function showLoginFormOrUserNotes(req, res) { 34 | + if (!req.session.csrfToken) { 35 | + req.session.csrfToken = require('crypto').randomBytes(36).toString('base64'); 36 | + res.setHeader("Set-Cookie", sessionCookie.serialize(req.session)); 37 | + } 38 | + 39 | if (req.query.msg) 40 | res.write('
' + Buffer(req.query.msg, 'hex') + '
\n'); 41 | if (!req.session.user) 42 | @@ -145,6 +154,9 @@ 43 | req.on('end', function() { 44 | var data = Buffer.concat(bodyChunks).toString(); 45 | req.body = querystring.parse(data); 46 | + if (!(req.body.csrfToken && req.session.csrfToken && 47 | + req.body.csrfToken == req.session.csrfToken)) 48 | + return next(403); 49 | route(req, res, next); 50 | }); 51 | } else route(req, res, next); 52 | -------------------------------------------------------------------------------- /test/solutions/html-escaping.diff: -------------------------------------------------------------------------------- 1 | --- app-vulnerable.js 2013-09-19 13:18:56.000000000 +0200 2 | +++ app.js 2013-09-20 09:33:55.000000000 +0200 3 | @@ -32,10 +32,27 @@ 4 | ].join('\n'); } 5 | }; 6 | 7 | +// https://github.com/janl/mustache.js/blob/master/mustache.js 8 | + 9 | +var entityMap = { 10 | + "&": "&", 11 | + "<": "<", 12 | + ">": ">", 13 | + '"': '"', 14 | + "'": ''', 15 | + "/": '/' 16 | +}; 17 | + 18 | +function escapeHtml(string) { 19 | + return String(string).replace(/[&<>"'\/]/g, function (s) { 20 | + return entityMap[s]; 21 | + }); 22 | +} 23 | + 24 | var routes = { 25 | 'GET /': function showLoginFormOrUserNotes(req, res) { 26 | if (req.query.msg) 27 | - res.write('
' + Buffer(req.query.msg, 'hex') + '
\n'); 28 | + res.write('
' + escapeHtml(Buffer(req.query.msg, 'hex')) + '
\n'); 29 | if (!req.session.user) 30 | return res.end(views.login(req)); 31 | app.db.get('notes-' + req.session.user, function(err, value) { 32 | -------------------------------------------------------------------------------- /test/solutions/httponly.diff: -------------------------------------------------------------------------------- 1 | --- app-vulnerable.js Mon Sep 23 06:11:58 2013 2 | +++ app.js Mon Sep 23 06:14:55 2013 3 | @@ -39,7 +39,7 @@ 4 | } catch (e) {} 5 | }, 6 | serialize: function(session) { 7 | - return 'session=' + Buffer(JSON.stringify(session)).toString('base64'); 8 | + return 'session=' + Buffer(JSON.stringify(session)).toString('base64') + '; HttpOnly'; 9 | }, 10 | clear: 'session=; Expires=Thu, 01 Jan 1970 00:00:00 GMT' 11 | }; 12 | -------------------------------------------------------------------------------- /test/solutions/redos.diff: -------------------------------------------------------------------------------- 1 | --- app-vulnerable.js 2013-09-19 13:18:56.000000000 +0200 2 | +++ app.js 2013-09-19 16:17:38.000000000 +0200 3 | @@ -6,7 +6,7 @@ 4 | 5 | var PORT = process.env.PORT || 3000; 6 | -var VALID_USERNAME = /^([A-Za-z0-9_]+)+$/; 7 | +var VALID_USERNAME = /^([A-Za-z0-9_]+)$/; 8 | 9 | // These view functions all return strings of HTML. 10 | var views = { 11 | -------------------------------------------------------------------------------- /test/solutions/test-solutions.js: -------------------------------------------------------------------------------- 1 | var VERIFY = '../../bin/verify'; 2 | var PROBLEMS = require(VERIFY).PROBLEMS; 3 | var path = require('path'); 4 | var fs = require('fs'); 5 | var test = require('tap-prettify').test; 6 | var child_process = require('child_process'); 7 | var fork = child_process.fork; 8 | var spawn = child_process.spawn; 9 | 10 | var TEST_FILTER = process.env.TEST_FILTER; 11 | var ROOT_DIR = path.normalize(path.join(__dirname, '..', '..')); 12 | var APP_VULNERABLE = path.join(ROOT_DIR, 'app-vulnerable.js'); 13 | var APP_PATCHED = path.join(ROOT_DIR, 'app-patched.js'); 14 | 15 | function newEnv(extras) { 16 | var newEnv = JSON.parse(JSON.stringify(process.env)); 17 | Object.keys(extras).forEach(function(name) { 18 | newEnv[name] = extras[name]; 19 | }); 20 | return newEnv; 21 | } 22 | 23 | Object.keys(PROBLEMS).filter(function(name) { 24 | if (!TEST_FILTER) return true; 25 | return TEST_FILTER.indexOf(name) != -1; 26 | }).forEach(function(name) { 27 | test("problem " + name + " fails w/ app-vulnerable", function(t) { 28 | var child = fork(VERIFY, [name], {env: newEnv({ 29 | APP_MODULE: APP_VULNERABLE, 30 | TEST_PROBLEM_ONLY: '' 31 | })}); 32 | child.on('exit', function(code) { 33 | t.ok(code != 0, "exit code should be nonzero"); 34 | t.end(); 35 | }); 36 | }); 37 | 38 | test("problem " + name + " works w/ solution", function(t) { 39 | var code = fs.readFileSync(APP_VULNERABLE); 40 | var patchFile = path.join(__dirname, name + '.diff'); 41 | 42 | if (!fs.existsSync(patchFile)) { 43 | t.skip(); 44 | return t.end(); 45 | } 46 | 47 | fs.writeFileSync(APP_PATCHED, code); 48 | 49 | var patch = spawn('patch', ['-s', '-f', '--no-backup-if-mismatch', 50 | APP_PATCHED, patchFile]); 51 | patch.stdout.on('data', process.stderr.write.bind(process.stderr)); 52 | patch.stderr.on('data', process.stderr.write.bind(process.stderr)); 53 | patch.on('exit', function(code) { 54 | t.equal(code, 0, "patch exits with code 0"); 55 | 56 | var child = fork(VERIFY, [name], {env: newEnv({ 57 | APP_MODULE: APP_PATCHED 58 | })}); 59 | child.on('exit', function(code) { 60 | t.equal(code, 0, "exit code should be zero"); 61 | fs.unlinkSync(APP_PATCHED); 62 | t.end(); 63 | }); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /test/test-app.js: -------------------------------------------------------------------------------- 1 | var test = require('tap-prettify').test; 2 | 3 | var testUtil = require('./lib'); 4 | var appRequest = testUtil.appRequest; 5 | var passwordStorage = testUtil.getApp().passwordStorage; 6 | 7 | test("GET / w/o session shows login form", function(t) { 8 | appRequest('/', function(err, res, body) { 9 | t.notOk(err); 10 | t.has(body, /login/); 11 | t.equal(res.statusCode, 200); 12 | t.end(); 13 | }); 14 | }); 15 | 16 | test("GET / w/ session shows notes, logout button", function(t) { 17 | appRequest({ 18 | url: '/', 19 | user: 'foo' 20 | }, function(err, res, body) { 21 | t.notOk(err); 22 | t.has(body, /logout foo/i, "logout button is visible"); 23 | t.has(body, /update notes/i, "update notes button is visible"); 24 | t.has(res.statusCode, 200); 25 | t.end(); 26 | }); 27 | }); 28 | 29 | test("GET /blarg returns 404", function(t) { 30 | appRequest('/blarg', function(err, res, body) { 31 | t.notOk(err); 32 | t.has(body, /alas/i); 33 | t.equal(res.statusCode, 404); 34 | t.end(); 35 | }); 36 | }); 37 | 38 | test("POST / w/o session returns 401", function(t) { 39 | appRequest({ 40 | method: 'POST', 41 | url: '/' 42 | }, function(err, res, body) { 43 | t.notOk(err); 44 | t.equal(res.statusCode, 401); 45 | t.end(); 46 | }); 47 | }); 48 | 49 | test("POST /login w/ bad credentials rejects user", function(t) { 50 | appRequest({ 51 | method: 'POST', 52 | url: '/login', 53 | form: { 54 | username: 'meh', 55 | password: 'meh', 56 | action: 'login' 57 | } 58 | }, function(err, res, body) { 59 | t.notOk(err); 60 | t.has(body, /invalid username or password/i); 61 | t.equal(res.statusCode, 200); 62 | t.end(); 63 | }); 64 | }); 65 | 66 | test("POST /login w/o password rejects user", function(t) { 67 | appRequest({ 68 | method: 'POST', 69 | url: '/login', 70 | form: { 71 | username: 'meh', 72 | password: '', 73 | action: 'login' 74 | } 75 | }, function(err, res, body) { 76 | t.notOk(err); 77 | t.has(body, /provide a password/i); 78 | t.equal(res.statusCode, 200); 79 | t.end(); 80 | }); 81 | }); 82 | 83 | test("POST /login w/ bad username rejects user", function(t) { 84 | appRequest({ 85 | method: 'POST', 86 | url: '/login', 87 | form: { 88 | username: 'meh.', 89 | password: 'meh', 90 | action: 'login' 91 | } 92 | }, function(err, res, body) { 93 | t.notOk(err); 94 | t.has(body, /invalid username \(only/i); 95 | t.equal(res.statusCode, 200); 96 | t.end(); 97 | }); 98 | }); 99 | 100 | test("POST /login w/ existing username rejects user", function(t) { 101 | appRequest({ 102 | method: 'POST', 103 | url: '/login', 104 | form: { 105 | username: 'meh', 106 | password: 'meh', 107 | action: 'register' 108 | } 109 | }, function(err, res, body, db) { 110 | t.notOk(err); 111 | t.equal(res.statusCode, 200); 112 | 113 | appRequest({ 114 | db: db, 115 | method: 'POST', 116 | url: '/login', 117 | form: { 118 | username: 'meh', 119 | password: 'meh', 120 | action: 'register' 121 | } 122 | }, function(err, res, body) { 123 | t.notOk(err); 124 | t.has(body, /user already exists/i); 125 | t.equal(res.statusCode, 200); 126 | t.end(); 127 | }); 128 | }); 129 | }); 130 | 131 | test("sessionCookie.parse() and .serialize() work", function(t) { 132 | var sessionCookie = testUtil.getApp().sessionCookie; 133 | 134 | t.deepEqual(sessionCookie.parse(sessionCookie.serialize({ 135 | foo: 'bar' 136 | })), {foo: 'bar'}); 137 | t.equal(sessionCookie.parse("LOL"), undefined); 138 | t.equal(sessionCookie.parse("session=LOL"), undefined); 139 | 140 | t.end(); 141 | }); 142 | 143 | test("passwordStorage.create() fails when acct exists", function(t) { 144 | var db = testUtil.level(); 145 | 146 | passwordStorage.create(db, 'blah', 'foo', function(err) { 147 | t.notOk(err, "works when acct did not already exist"); 148 | passwordStorage.create(db, 'blah', 'foo', function(err) { 149 | t.ok(/exists/.test(err), "reports err when acct already exists"); 150 | t.end(); 151 | }); 152 | }); 153 | }); 154 | 155 | test("passwordStorage.check() works when pass doesn't exist", function(t) { 156 | var db = testUtil.level(); 157 | 158 | passwordStorage.check(db, 'blah', 'meh', function(err, ok) { 159 | t.notOk(err); 160 | t.equal(ok, false); 161 | t.end(); 162 | }); 163 | }); 164 | 165 | test("passwordStorage.check() returns true", function(t) { 166 | var db = testUtil.level(); 167 | 168 | passwordStorage.create(db, 'blah', 'foo', function(err) { 169 | t.notOk(err, "works when acct did not already exist"); 170 | passwordStorage.check(db, 'blah', 'foo', function(err, ok) { 171 | t.notOk(err); 172 | t.ok(ok, 'check returns true when password matches'); 173 | t.end(); 174 | }); 175 | }); 176 | }); 177 | 178 | test("passwordStorage.check() returns false", function(t) { 179 | var db = testUtil.level(); 180 | 181 | passwordStorage.create(db, 'blah', 'foo', function(err) { 182 | t.notOk(err, "works when acct did not already exist"); 183 | passwordStorage.check(db, 'blah', 'FOO', function(err, ok) { 184 | t.notOk(err); 185 | t.notOk(ok, 'check returns false when password doesn\'t match '); 186 | t.end(); 187 | }); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /test/wdtest-app.js: -------------------------------------------------------------------------------- 1 | var wdtest = require('./lib').wdtest; 2 | 3 | wdtest.setup(); 4 | 5 | wdtest("users can register and store notes", function(t) { 6 | t.browser.get('/'); 7 | 8 | // Register a user... 9 | t.browser.$('input[name="username"]').type("me"); 10 | t.browser.$('input[name="password"]').type("blarg"); 11 | t.browser.$('button[value="register"]').click(); 12 | 13 | // Write some notes... 14 | t.browser.$('textarea').type("hallo, these are my notes."); 15 | t.browser.$('input[value="Update Notes"]').click(); 16 | 17 | // Log out... 18 | t.browser.$('input[value="Logout me"]').click(); 19 | 20 | // Log back in... 21 | t.browser.$('input[name="username"]').type("me"); 22 | t.browser.$('input[name="password"]').type("blarg"); 23 | t.browser.$('button[value="login"]').click(); 24 | 25 | // Read our notes... 26 | t.equal(t.browser.$('textarea').text(), 'hallo, these are my notes.'); 27 | 28 | // Verify that stuff was stored in our database. 29 | t.ok(t.db.get('password-me')); 30 | t.equal(t.db.get('notes-me'), 'hallo, these are my notes.'); 31 | }); 32 | 33 | wdtest.teardown(); 34 | --------------------------------------------------------------------------------