Security Testing
Testing that your defenses actually work
You have added defenses throughout this course. But how do you know they work? How do you know they will keep working after the next code change?
Security testing has two parts: manual testing (trying attacks yourself) and automated testing (writing test cases that run on every change).
Manual testing with curl
Every attack in this course can be tested with curl. Here is a checklist:
SQL injection
# Should return only your notes, not all notes
curl -b cookies.txt "http://localhost:3000/notes/search?q=%27%20OR%20%271%27%3D%271"
# Expected: empty array (the injection is treated as a literal search string) XSS (if you serve HTML)
# Should return encoded HTML, not executable script
curl -b cookies.txt http://localhost:3000/notes/NOTE_ID/view
# Check that <script> appears as <script> in the response IDOR
# Alice should NOT see the admin's note
curl -b cookies.txt http://localhost:3000/notes/note-3
# Expected: 404 Not Found Path traversal
curl -b cookies.txt http://localhost:3000/files/..%2F..%2Fetc%2Fpasswd
# Expected: 400 Invalid filename SSRF
curl -b cookies.txt -X POST http://localhost:3000/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"http://169.254.169.254/latest/meta-data/"}'
# Expected: 400 URL not allowed Mass assignment
curl -b cookies.txt -X POST http://localhost:3000/notes \
-H "Content-Type: application/json" \
-d '{"title":"Test","body":"Body","user_id":"admin-1"}'
# Then check: the note should be owned by alice (user-1), not admin-1 Automated testing
Manual testing is good for exploration. Automated testing ensures defenses survive code changes.
Using Node.js’s built-in test runner (or any test framework), you can write tests that call your app directly:
// tests/security.test.ts
import { describe, it, assert } from "node:test";
import { app } from "../src/app.js";
async function request(path: string, options?: RequestInit) {
return app.fetch(new Request(`http://localhost:3000${path}`, options));
}
describe("SQL injection", () => {
it("parameterized queries block injection", async () => {
// Log in first
const loginRes = await request("/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "[email protected]", password: "password123" }),
});
const cookie = loginRes.headers.get("set-cookie")!;
// Try SQL injection
const res = await request("/notes/search?q=%27%20OR%20%271%27%3D%271", {
headers: { cookie },
});
const notes = await res.json();
// Should NOT return the admin's note
assert.ok(!notes.some((n: any) => n.user_id === "admin-1"));
});
});
describe("IDOR", () => {
it("users cannot access other users notes", async () => {
const loginRes = await request("/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "[email protected]", password: "password123" }),
});
const cookie = loginRes.headers.get("set-cookie")!;
const res = await request("/notes/note-3", { headers: { cookie } });
assert.strictEqual(res.status, 404);
});
}); The key pattern: test the attack, not just the happy path. A test that creates a note and reads it back verifies functionality. A test that tries to read another user’s note verifies security.
What to test
For each vulnerability type, write at least two tests:
- The attack fails: The defense blocks the malicious input
- Legitimate use works: The defense does not break normal functionality
This prevents both false negatives (the defense does not work) and false positives (the defense blocks legitimate requests).
Exercises
Exercise 1: Run through the manual testing checklist above. Does every defense hold?
Exercise 2: Write automated tests for IDOR and SQL injection using the pattern shown above. Run them with node --test.
Exercise 3: Add a test for mass assignment: create a note with user_id: "admin-1" in the body. Assert that the created note is owned by the authenticated user, not by admin-1.
Why should security tests check both attack failure AND legitimate use?