Free lesson 1: The Secure Coding Map: Find, Fix and Test Your First Vulnerable Function

Most security flaws are not exotic. They are ordinary lines of code that trust a value they should not trust, and they look perfectly reasonable in a pull request. This lesson gives you the one mental model that explains almost every coding vulnerability โ untrusted sources flowing into dangerous sinks โ and then walks you through the full routine on real Python and JavaScript code: find the vulnerable function, understand why it breaks, fix it properly and prove the fix with a test.
- 12lessons in the course
- 2languages in every example: Python and JavaScript
- 4steps per bug: find, understand, fix, test
- ~20 minreading time for this lesson
Practise only on code and systems you own or are explicitly authorised to test. Every example in this course is a short vulnerable pattern followed directly by its fix, written for your own projects and lab environments. Nothing here is meant to be used against someone else's application.
Why secure coding matters: the bug is in the line you already wrote
From a developer's chair, most security stories are small. Someone built a query by gluing strings together. Someone checked that a user was logged in but not that the invoice belonged to them. Someone left a debug flag on, and an error page printed a connection string. None of these needed a genius to write โ or to prevent.
It helps to use three words precisely, because the industry often blurs them:
- A weakness is a type of mistake, such as "SQL built from untrusted strings". The CWE list catalogues these types; SQL injection is CWE-89, path traversal is CWE-22, cross-site scripting is CWE-79.
- A vulnerability is a weakness that is actually reachable in a specific product: an attacker-controlled value really does arrive at that query.
- An exploit is a concrete technique that abuses the vulnerability. As a developer, you rarely need the exploit. You need to recognise the weakness and remove the path.
That is the attitude of this course: no attack recipes, but the ability to see the shape of a vulnerable line, name the weakness and replace it with a pattern that is safe by construction. That skill transfers between languages far better than any list of payloads.
Why fixing it in code beats fixing it later
A flaw found while you write the function costs minutes: you know the context and the fix ships with the feature. Found in production, the same flaw needs an investigation, a hotfix, a release and possibly a notification to users. Firewalls, scanners and penetration tests see your code from the outside; the person with the clearest view of how data moves through a function is the person who wrote it.
Lists like the OWASP Top 10 (2021 edition and the 2025 update) group web risks into broad categories. They are excellent for awareness, but you cannot "implement" a category. This course turns them into concrete coding habits: which API to use at which sink, which check belongs on which line, and which test proves that the check works.
The mental model: sources, sinks and trust boundaries
Almost every coding vulnerability fits one sentence: an untrusted value reaches a sensitive operation without the right protection for that operation. Break the sentence into parts and you get a model you can apply to any codebase.
- Source โ where data enters that you do not control: URL parameters, form fields, JSON bodies, headers, cookies, uploaded files, message queues, webhooks, and data read back from your own database that originally came from users.
- Trust boundary โ the line where data changes from "someone else's" to "ours". An HTTP handler is a boundary; so is a function that reads a file uploaded by a customer.
- Sink โ an operation where data can change behaviour instead of just being stored or displayed as text: a SQL engine, a shell, an HTML page, the file system, an outgoing HTTP client, a deserialiser, a template engine,
eval. - Decision points โ places where your code decides who may do what. Missing decisions are as dangerous as broken sinks: an authorisation bug has no "malicious characters" at all.
- 1 Sourcewhere does the value come from?
- 2 Boundarywhere does it cross into our code?
- 3 Shapeis type, length and format checked?
- 4 Decisionis the caller allowed to do this?
- 5 Sinkwhich API protects the operation?
The key insight is that the strongest defence is chosen by the sink, not by the source. The same string can be harmless in a log line, dangerous in a SQL query and dangerous in a completely different way inside HTML. That is why "sanitise all input" is a weak rule: you cannot know at the boundary which characters will be dangerous later. Validation at the boundary checks that data has the expected shape; the sink-specific API makes sure data is never interpreted.
| Sink | Weakness (CWE) | Safe pattern (Python / JavaScript) | Lesson |
|---|---|---|---|
| SQL query | SQL injection (CWE-89) | Placeholders: cur.execute(sql, params) / client.query(sql, [values]) | 3 |
| HTML page | Cross-site scripting (CWE-79) | Auto-escaping templates; textContent instead of innerHTML | 2 |
| Operating-system command | OS command injection (CWE-78) | Argument lists without a shell: subprocess.run([...]) / execFile(cmd, args) | 3 |
| File path | Path traversal (CWE-22) | Map IDs to files, or resolve the path and check it stays inside the base folder | 1, 8 |
| Outgoing HTTP request | Server-side request forgery (CWE-918) | Allow-list of destinations, block internal addresses, no blind redirects | 8 |
| Deserialiser | Deserialisation of untrusted data (CWE-502) | Data-only formats such as JSON plus schema validation; never pickle on untrusted input | 8 |
| Object lookup by ID | Authorisation bypass via user-controlled key (CWE-639) | Scope every query to the current user or tenant, deny by default | 5 |
| Password store | Weak password hash (CWE-916) | Argon2id, scrypt or bcrypt through a maintained library | 4 |
When you review code, use this table backwards: find the sinks, then ask where each value comes from and what protects it on the way.
Worked example: find, understand, fix and test a vulnerable lookup
Here is a function you could find in any small Python service. It looks up a customer by e-mail address in SQLite. It works in every manual test. Read it once and see whether you spot the problem before the walkthrough does.
# customers.py (vulnerable version - do not copy)
import sqlite3
def find_customer(conn: sqlite3.Connection, email: str):
sql = f"SELECT id, name, email FROM customers WHERE email = '{email}'"
return conn.execute(sql).fetchall()
- Find the sink.
conn.execute(sql)hands a string to the SQL engine, which parses it as code. That is the sink. Anything insidesqlbecomes part of the program the database runs. - Trace the source.
emailcomes from the caller. Search for callers and you will typically find an HTTP handler that passes a query parameter straight through. So the value is attacker-controlled, and it crosses the trust boundary unchanged. - Name the weakness. The f-string mixes code (the query text) and data (the e-mail) in one string. A quote character inside the e-mail ends the string literal early, and whatever follows is parsed as SQL. That is CWE-89, SQL injection.
- Understand the impact, not the exploit. If the value can change the query's logic, it can widen the
WHEREclause and return every customer instead of one. You do not need more detail than that to know this is serious: the function's intent ("return one customer") can be overridden by its input. - Fix it at the sink. Replace string building with a placeholder and pass the value separately. The query text becomes a constant, so no input can change its structure.
- Add shape validation as a second layer. An e-mail parameter should be a short string that looks like an address. Rejecting a 5000-character value early is cheap and keeps garbage out of logs and databases. It is a supporting control โ the placeholder is the real fix.
- Prove it with a test. Write one test for the normal case and one for a perfectly legitimate address that contains an apostrophe, such as
o'brien@example.com. The secure version must treat the apostrophe as data and find the right customer. You do not need an attack string to prove the bug: an honest customer already breaks the vulnerable query.
The fixed version:
# customers.py (fixed version)
import re
import sqlite3
EMAIL_SHAPE = re.compile(r"[^@\s]{1,64}@[^@\s]{1,255}")
def find_customer(conn: sqlite3.Connection, email: str):
if not isinstance(email, str) or len(email) > 320 or not EMAIL_SHAPE.fullmatch(email):
raise ValueError("invalid e-mail") # shape check: supporting layer
sql = "SELECT id, name, email FROM customers WHERE email = ?" # constant query text
return conn.execute(sql, (email,)).fetchall() # value bound separately
Notice what the fix does not do: strip quotes, double them or block words like OR. Hand-escaping depends on the dialect, the encoding and on remembering it at every call site. A placeholder removes the whole class of problem โ the difference between a patch and a design.
The tests that prove the fix
# test_customers.py - run with: python -m pytest -q
import sqlite3
import pytest
from customers import find_customer
@pytest.fixture
def conn():
c = sqlite3.connect(":memory:")
c.execute("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")
c.executemany("INSERT INTO customers (name, email) VALUES (?, ?)",
[("Ada", "ada@example.com"), ("Siobhan", "o'brien@example.com")])
return c
def test_finds_exact_customer(conn):
rows = find_customer(conn, "ada@example.com")
assert [r[1] for r in rows] == ["Ada"]
def test_apostrophe_is_data_not_sql(conn):
# A legal address: the apostrophe must be compared, never parsed.
rows = find_customer(conn, "o'brien@example.com")
assert [r[1] for r in rows] == ["Siobhan"]
def test_unknown_address_returns_nothing(conn):
assert find_customer(conn, "nobody@example.com") == []
def test_rejects_wrong_shape(conn):
with pytest.raises(ValueError):
find_customer(conn, "a" * 5000)
Run the tests against the vulnerable version first. The apostrophe test fails there with sqlite3.OperationalError, a syntax error: the apostrophe closed the string literal early and the rest of the address was parsed as SQL. That error message is the injection, seen from the friendly side. Then run the tests against the fixed version. What you should see (example output; timings and formatting vary by pytest version):
$ python -m pytest -q
.... [100%]
4 passed in 0.02s
This "red, then green" order matters. A security test that has never failed proves nothing โ it may not even reach the code you think it does. Keep it in the suite: if someone later "simplifies" the query back into an f-string, the build turns red.
Name security tests after the property they protect, not after the attack: test_apostrophe_is_data_not_sql tells the next developer exactly what must stay true. A name like test_sqli_payload invites someone to "update the payload" instead of understanding the rule.
Under the hood: why a placeholder makes injection impossible
The principle behind the fix โ keep code and data in separate channels โ returns in almost every lesson, so it is worth seeing it work. When you call conn.execute(sql) with a single string, the database receives one blob of text. Its tokenizer splits the text into keywords, identifiers, operators and string literals. A string literal starts at a quote and ends at the next unescaped quote. The tokenizer has no idea which characters came from your source code and which came from a customer. If the customer's value contains a quote, the literal ends where the customer decided, and the following characters become tokens of the query. That is the whole mechanism of SQL injection.
With a placeholder the sequence changes. In SQLite, for example, Python's sqlite3 module prepares the statement text ... WHERE email = ?, which compiles it into an internal program with an empty parameter slot. Then it binds your value to that slot as a typed value (text, in this case), and only then executes the program. The value never passes through the tokenizer, so a quote inside it is just a byte in a string that gets compared with the column.
Three details separate people who have really understood this from people who memorised "use prepared statements":
- Identifiers cannot be bound. Placeholders stand for values, not for table names, column names or sort directions. If a user may choose the sort column, map their choice to a fixed allow-list on the server (
{"name": "name", "newest": "created_at"}) and insert only the mapped constant. - Some drivers emulate placeholders by quoting the value on the client side. That is still safe, because the driver applies the dialect's rules consistently โ which is exactly why you should never assemble part of the query yourself.
- A bound value can still carry meaning inside an operator. In
WHERE name LIKE ?, a bound%or_still acts as a wildcard. That is not injection โ the query structure is intact โ but it can turn a "starts with" search into a full-table scan. Escape wildcard characters and declare anESCAPEcharacter when the user should search literally.
A second, deeper example: path traversal in a Node.js download handler
Here the sink is the file system and the dangerous characters are ordinary path syntax. An Express route lets a logged-in customer download an invoice PDF by name:
// download.js (vulnerable version - do not copy)
const express = require('express');
const path = require('node:path');
const fs = require('node:fs');
const app = express();
const BASE = path.join(__dirname, 'invoices');
app.get('/download', (req, res) => {
const file = path.join(BASE, req.query.name); // the caller decides the path
fs.createReadStream(file).pipe(res);
});
Apply the model. The source is req.query.name. The sink is the file system read. The weakness is that path.join does not confine anything: it simply concatenates segments and normalises them, so a .. segment in the name moves the result up out of the invoices folder. That is CWE-22, path traversal. Any file the Node.js process can read โ configuration, environment files, source code โ becomes downloadable. There are two quieter bugs as well. Express can turn a repeated parameter such as ?name=a&name=b into an array, and path.join then throws a TypeError, which the default error handler turns into a 500 page โ with a stack trace when the app runs in development mode. And because the read stream has no error listener, a request for a file that does not exist emits an unhandled 'error' event, which crashes the whole Node.js process. One missing check, three failure modes: data exposure, information leakage and denial of service.
The strongest fix avoids user-supplied paths entirely: store invoices under generated IDs, look up the file path in the database for the current user, and never let the client name a file. When a name genuinely has to come from the client, enforce two independent rules: a strict shape, and containment after resolution.
// safe-path.js (fixed helper)
const path = require('node:path');
const BASE = path.resolve(__dirname, 'invoices');
const NAME_SHAPE = /^[A-Za-z0-9_-]{1,64}\.pdf$/; // allow-list of characters
function resolveInside(base, userName) {
if (typeof userName !== 'string' || !NAME_SHAPE.test(userName)) {
throw new Error('invalid file name');
}
const target = path.resolve(base, userName); // absolute, normalised
if (!target.startsWith(base + path.sep)) { // must stay below base
throw new Error('path escapes base directory');
}
return target;
}
module.exports = { BASE, resolveInside };
// download.js (fixed route)
const { BASE, resolveInside } = require('./safe-path');
app.get('/download', (req, res) => {
let file;
try {
file = resolveInside(BASE, req.query.name);
} catch {
return res.status(400).send('Invalid file name');
}
// Lesson 5 adds the missing piece: does this invoice belong to the caller?
res.sendFile(file, (err) => {
if (err && !res.headersSent) res.status(404).send('Not found');
});
});
Either rule alone stops the obvious case today. Together they survive tomorrow's change, when someone relaxes the name pattern and forgets why it was strict. That is defence in depth inside one function.
// safe-path.test.js - run with: node --test
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { BASE, resolveInside } = require('./safe-path');
test('accepts a plain invoice name', () => {
assert.equal(resolveInside(BASE, 'inv_2026-001.pdf'), path.join(BASE, 'inv_2026-001.pdf'));
});
test('rejects names that climb out of the base folder', () => {
assert.throws(() => resolveInside(BASE, '../secrets.pdf'));
assert.throws(() => resolveInside(BASE, '/etc/hosts'));
});
test('rejects non-string input such as ?name=a&name=b', () => {
assert.throws(() => resolveInside(BASE, ['a.pdf', 'b.pdf']));
});
Example output with a recent Node.js version (the reporter format differs between versions):
$ node --test
โ accepts a plain invoice name (0.7ms)
โ rejects names that climb out of the base folder (0.3ms)
โ rejects non-string input such as ?name=a&name=b (0.2ms)
โน tests 3
โน suites 0
โน pass 3
โน fail 0
โฆ (more summary lines follow)
What went wrong if you see this
- The containment check passes for a sibling folder. You compared with
startsWith(base)instead ofstartsWith(base + path.sep). A folder calledinvoices-archivestarts with the same characters asinvoices. - The test passes but production still serves outside files. Check for symbolic links inside the base folder.
path.resolveworks on strings and does not follow links; if links are possible, resolve the real path withfs.realpathSyncand check containment again. - Encoded dots get through. Express decodes the query string once. If your code calls
decodeURIComponentagain after validation, a double-encoded value is decoded after the check. Validate the final value that reaches the sink, never an earlier form of it.
Common secure coding mistakes โ and what to do instead
Both examples share a pattern: the tempting fix works on the obvious case and fails on the next one.
- "Sanitise all input" at the entry point and trust it afterwards
- Escaping quotes or stripping
../by hand - Blocklists of "bad" words or characters
- Validation only in the browser or the mobile app
- Trusting values because they came from your own database
- Returning the exception text to the caller
- Check shape at the boundary, protect with the right API at each sink
- Placeholders, argument lists, auto-escaping, ID-to-file maps
- Allow-lists of what is valid; reject everything else
- Repeat every check on the server โ the client is not yours
- Treat stored user data as untrusted when it reaches a new sink
- A generic message for the user, the details in the log
Isn't strict input validation enough on its own?
No. Validation answers "does this look like an e-mail address?", and a legitimate address can still contain an apostrophe. If validation is your only defence, you must either reject real customers or let dangerous characters through. Validation reduces noise and catches garbage; the sink-specific API is what makes the operation safe.
Does an ORM or a modern framework make me safe automatically?
It makes the default path safe โ ORM query builders bind values, and template engines such as Jinja2 in Flask or React's JSX escape output. Vulnerabilities appear at the escape hatches: raw SQL helpers fed with formatted strings, |safe filters, dangerouslySetInnerHTML, or a dynamic column name. Learn where your framework's escape hatches are and review them first.
Can a web application firewall replace the code fix?
A WAF can block known attack shapes and buy time while you patch. It does not understand your business logic and cannot see authorisation bugs at all. Treat it as a seatbelt, not as the brakes.
Troubleshooting: when the fix does not behave
Real fixes rarely work on the first try. These are the problems developers typically hit when they apply the patterns from this lesson, and how to resolve them.
| Symptom | Likely cause | Fix |
|---|---|---|
sqlite3.ProgrammingError: Incorrect number of bindings supplied | You passed (email), which is just a string in brackets, so each character counts as one parameter | Write a one-element tuple with a trailing comma: (email,) |
| Query works with SQLite, fails with PostgreSQL | Placeholder style differs between drivers | Use the driver's style: ? for sqlite3, %s for psycopg, $1 for node-postgres |
| Sorting by a placeholder has no effect or errors | Identifiers cannot be bound; the column name became a constant string | Map the user's choice to an allow-list of column names |
| Real customers are rejected after adding validation | Pattern too strict: plus addressing, apostrophes, long domains | Validate shape loosely; prove ownership with a confirmation message |
How experienced reviewers work through a change
Professionals do not read a pull request top to bottom. They follow a fixed order that spends time where bugs live and ignore what tools already cover, such as formatting. A typical budget for a medium-sized change:
- Scope โ 5 minutesWhat does the change do, and which trust boundaries does it touch? A new route, a new upload, a new outgoing call or a new dependency raises the priority.
- Sinks first โ 15 minutesSearch the diff for
execute(,innerHTML,subprocess,child_process,open(,fs.,fetch(,pickle,evaland template escape hatches. - Trace back โ 15 minutesFor each sink, follow the value to its source. Can a caller influence it? What protects it on the way?
- Decisions โ 10 minutesEvery new endpoint: who may call it, and is ownership of each object checked on the server?
- Failure paths โ 10 minutesErrors, timeouts, retries and logs. What does the user see, and what lands in the log?
- Evidence โ 5 minutesIs there a test that would fail if the security check were deleted? If not, ask for one.
The thinking errors of experienced developers are assumptions, not syntax: "this endpoint is internal"; "the value comes from our own table"; "the framework escapes everything"; "the frontend validates it". Each fails silently the day the system around it changes. A good reviewer writes the assumption down and checks whether the code enforces it.
The defender's view: make the safe pattern the only pattern
Fixing one function is good. Making sure the vulnerable pattern cannot come back is better. Defenders work on three levels at once.
1. Detect the pattern automatically
Static analysis tools can flag the exact shape you removed today. A small custom rule for Semgrep, for example, catches SQL built with string formatting before it is merged:
# rules/sql-format.yml - run with: semgrep scan --config rules/sql-format.yml .
rules:
- id: python-sql-built-from-strings
languages: [python]
severity: ERROR
message: SQL text is built from strings. Use a constant query with placeholders.
pattern-either:
- pattern: $DB.execute(f"...", ...)
- pattern: $DB.execute("..." % $X, ...)
- pattern: $DB.execute("..." + $X, ...)
- pattern: $DB.execute("...".format(...), ...)
Test the rule like code: it must flag the vulnerable version from this lesson and stay silent on the fixed one. A rule that has never fired is as untrustworthy as a test that has never failed.
2. Limit the damage when something slips through
Run the application's database account with only the rights it needs โ no schema changes, no access to other applications' tables. Run the Node.js process as an unprivileged user that cannot read configuration it does not need. Neither prevents the bug, but both shrink what a bug can reach.
3. Notice abuse
Validation failures are a signal. A burst of rejected, path-shaped values against one endpoint is worth an alert. Log the event, the route and a request ID โ not the raw value if it might contain personal data. Lesson 7 builds this into a proper logging design.
Check yourself: six questions on the secure coding basics
1. A function builds "SELECT * FROM orders WHERE ref = '" + ref + "'". What is the root-cause fix?
- Remove quotes from
refbefore building the string - Use a constant query with a placeholder and pass
refas a parameter - Put a web application firewall in front of the service
Show answer
B โ the placeholder keeps the query structure fixed, so the value can never become code. Stripping quotes is fragile and a WAF is only an extra layer.
2. Why is "sanitise all input at the entry point" a weak rule?
- Because the dangerous characters depend on the sink the value reaches later
- Because input validation is slow
- Because frameworks forbid it
Show answer
A โ a quote is harmless in a log line and dangerous in SQL; angle brackets matter in HTML but not in a file name. Check shape at the boundary, protect at each sink.
3. A user may choose the sort column of a report. Placeholders do not work for column names. What should you do?
- Escape the column name
- Allow only letters in the value
- Map the choice to a fixed allow-list of column names on the server
Show answer
C โ identifiers cannot be bound, so the server must choose the identifier from its own list. The user only selects a key.
4. A containment check uses target.startsWith(base). What is the flaw?
- It is too slow for large folders
- A sibling folder whose name starts with the same characters also passes
- It does not work with absolute paths
Show answer
B โ /srv/invoices-archive starts with /srv/invoices. Compare with base + path.sep.
5. You wrote a security test and it passes. What should you check before trusting it?
- That it fails against the vulnerable version of the code
- That it runs in under one second
- That it uses a real attack payload
Show answer
A โ a test that has never failed may not reach the code you think it reaches. Red on the vulnerable version, green on the fix, is your evidence.
6. Which statement about an ORM is correct?
- An ORM makes SQL injection impossible in every case
- ORMs never bind parameters
- The default query builder is safe; raw-SQL helpers fed with formatted strings are not
Show answer
C โ the risk lives at the escape hatches. Review raw queries, dynamic identifiers and template "safe" filters first.
- Name the parts โ identify source, trust boundary, decision point and sink in a function you did not write
- Choose by sink โ pick the defence that matches the operation, not the input
- Fix SQL properly โ replace string-built queries with placeholders and allow-list identifiers
- Contain file paths โ resolve a path and verify it stays inside its base folder, or avoid client-supplied names entirely
- Layer your checks โ combine shape validation with the sink defence so one mistake does not reopen the hole
- Prove it โ write a test that is red on the vulnerable code and green on the fix
- Review in order โ sinks first, then sources, decisions, failure paths and evidence
- Keep it fixed โ add a static-analysis rule and least-privilege accounts so the bug cannot return quietly
Glossary
? or $1 in query text that is filled by a separately bound value... to reach files outside the intended folder (CWE-22).What's next in this course
- Lesson 2 โ Input validation & output encoding: designing validation that rejects garbage without rejecting customers, and encoding output correctly for HTML, attributes, URLs and JavaScript.
- Lesson 3 โ Injection classes & parameterised queries: SQL, NoSQL, OS command, template and LDAP injection, each with its safe API in Python and JavaScript.
- Lesson 4 โ Authentication, sessions & password storage: Argon2id and bcrypt in practice, session cookies, token lifetimes and login rate limiting.
- Lesson 5 โ Authorisation done right: IDOR and BOLA, object-level checks, tenant scoping and deny-by-default middleware.
- Lesson 6 โ Using cryptography correctly: choosing vetted libraries, authenticated encryption, secure randomness and key handling โ without inventing your own.
- Lesson 7 โ Secrets, error handling & security logging: keeping credentials out of code, failing safely and logging what defenders need.
- Lesson 8 โ Files, deserialisation & SSRF defence: safe uploads, archive extraction, data-only formats and outgoing-request allow-lists.
- Lesson 9 โ Memory safety: what buffer overflows and use-after-free mean, why memory-safe languages matter, and where Python and Node.js still touch native memory.
- Lesson 10 โ Dependencies & the supply chain: lockfiles, hashes, audits, install scripts and how to evaluate a package before you add it.
- Lesson 11 โ Threat modelling, code review & security tests: function-level threat models, a review checklist, unit, property-based and fuzz tests.
- Lesson 12 โ Capstone & next steps: review and fix a complete small service end to end, plus 20 practice questions.
Lessons 2โ12 and the final exam unlock with a one-time purchase โ no subscription.