├── .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 | [](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 |
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 | ''
21 | ].join('\n'); },
22 | notes: function(req, notes) { return [
23 | '',
26 | ''
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 | - ''
19 | ].join('\n'); },
20 | notes: function(req, notes) { return [
21 | - '',
25 | - ''
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 |
--------------------------------------------------------------------------------