Most breaches still start the same way. Some text a user typed gets glued into a string, and later a parser reads that string and treats part of it as a command. That is injection, and it is really a formatting bug. ASVS chapter V1 Encoding and Sanitization is the set of rules that stops it, and eight of its requirements apply at Level 1.
This part covers all eight with code you can run. Each one is shown twice: a broken version and a fixed version, marked so you cannot mix them up. The two files are almost identical, usually one line apart, because that is how these bugs really happen. Everything exists in both Node.js and Go behind the tabs below. Pick your stack once and the whole article follows it.
You need no security background to follow along. If you can write a route handler and run a script, you can do this.
Conceptual Overview
Three words sound similar and mean different things. Getting them straight makes the rest easy.
Validation asks: is this input allowed at all? Is this a real email address? Is this quantity between 1 and 99? That is chapter V2, the next part.
Encoding rewrites data so a parser reads it as plain text instead of as code. Turning < into < before putting it in a page is encoding. Nothing is thrown away. The text still says what it said, but the browser can no longer mistake it for a tag.
Sanitization throws away the dangerous parts of content you have to keep. If a user writes a comment in a rich text editor, you cannot encode the whole thing, because the <b> tags are the feature. So you parse it and delete what is not on your allowed list.
The one idea behind all of V1 is context. Data is never just “escaped”. It is escaped for one specific place. The same username needs different treatment depending on where it lands:
- In an HTML element,
<becomes<. - In a quoted HTML attribute,
"becomes". - In a URL query string,
&becomes%26. - In JavaScript,
<becomes\u003c. - In SQL, it should not be in the query text at all. It gets sent separately, as a parameter.
The second idea: encode when you write the data out, not when it comes in. It is tempting to clean everything at the front door and store the clean copy. Do not. When the data arrives you do not know yet where it will go. It might end up in a web page, a CSV file, a PDF, or a log line, and each one needs different treatment. Store what the user actually typed, and encode at the moment you use it.
One more term you will meet below. An allowlist says what is permitted and rejects everything else. A denylist says what is forbidden and permits everything else. Denylists lose, because an attacker only needs one variation you did not think of. Every fix in this article is an allowlist.
Prerequisites
- Ubuntu 24.04 LTS with
sudoaccess. - Node.js 22 or later, or Go 1.25 or later. Both Build a REST API with Fastify and MySQL on Ubuntu and Build a REST API in Go with Fiber v3 on Ubuntu start from installation.
- PostgreSQL, so you can make a throwaway database. See Install and Configure PostgreSQL on Ubuntu.
- Somewhere to record what you close, such as a Markdown file in your repository. Part 1 lists all 70 Level 1 requirements and explains what passing one means.
Step 1: Set Up a Scratch Project
Every control gets two tiny programs, one named -bad and one named -good. Run the bad one and watch the attack work. Run the good one and watch it fail. Keep both, because the pair makes a ready-made test later.
mkdir -p ~/asvs-v1/node
cd ~/asvs-v1/node
npm init -y
npm pkg set type=module
npm install ejs pg isomorphic-dompurify fast-xml-parser
Each program is one file, run with node <name>.mjs.
mkdir -p ~/asvs-v1/go/cmd
cd ~/asvs-v1/go
go mod init asvs-v1
go get github.com/jackc/pgx/v5 github.com/microcosm-cc/bluemonday
Each program lives in its own folder under cmd/, run with go run ./cmd/<name>.
Step 2: Encode Output for the Place It Lands
V1.2.1 Verify that output encoding for an HTTP response, HTML document, or XML document is relevant for the context required, such as encoding the relevant characters for HTML elements, HTML attributes, HTML comments, CSS, or HTTP header fields, to avoid changing the message or document structure.
Say a user sets their name to <script>alert(1)</script>. If you print that name into a page without encoding it, the browser does not see a name. It sees a script tag, and it runs it. That is cross-site scripting, or XSS.
EJS is a common template engine for Node. It has two output tags, and they are one character apart. <%- prints the value as-is. Create encoding-bad.mjs:
import ejs from 'ejs'
const template = '<h1>Hello, <%- name %></h1>'
console.log(ejs.render(template, { name: '<script>alert(1)</script>' }))
<%= encodes it first. That is the only change in encoding-good.mjs:
import ejs from 'ejs'
const template = '<h1>Hello, <%= name %></h1>'
console.log(ejs.render(template, { name: '<script>alert(1)</script>' }))
$ node encoding-bad.mjs
<h1>Hello, <script>alert(1)</script></h1>
$ node encoding-good.mjs
<h1>Hello, <script>alert(1)</script></h1>
In the second output the browser prints the characters <script> on the page. It does not run anything.
EJS only knows how to encode for HTML. It knows nothing about JavaScript or CSS. So use <%= %> inside elements and inside quoted attributes, always put quotes around your attributes, and never drop user data straight into a <script> or <style> block. Step 4 covers what to do when you need data in JavaScript.
Go ships two template packages with the same function names. Only the import line tells them apart, so picking the wrong one still compiles and encodes nothing. Create cmd/encoding-bad/main.go:
package main
import (
"os"
"text/template"
)
func main() {
t := template.Must(template.New("greet").Parse(`<h1>Hello, {{.Name}}</h1>`))
t.Execute(os.Stdout, map[string]string{"Name": `<script>alert(1)</script>`})
}
cmd/encoding-good/main.go is the same file with one word changed:
package main
import (
"os"
"html/template"
)
func main() {
t := template.Must(template.New("greet").Parse(`<h1>Hello, {{.Name}}</h1>`))
t.Execute(os.Stdout, map[string]string{"Name": `<script>alert(1)</script>`})
}
$ go run ./cmd/encoding-bad
<h1>Hello, <script>alert(1)</script></h1>
$ go run ./cmd/encoding-good
<h1>Hello, <script>alert(1)</script></h1>
In the second output the browser prints the characters <script> on the page. It does not run anything.
html/template is smarter than it looks. It works out where each value sits and picks the right encoding for that spot: inside an href it URL-encodes, inside a <script> it uses JavaScript escapes. You get that for free, as long as you import the right package. Search your project for text/template and check every result really is producing something other than HTML.
The requirement also mentions HTTP header fields. If you copy user input into a response header, a newline in that input can start a fake second header. Node throws an error and returns a 500; Go replaces the newline with a space. Neither is an attack, and neither is what you wanted, so check header values against an allowlist first.
Step 3: Build URLs with a URL Library
V1.2.2 Verify that when dynamically building URLs, untrusted data is encoded according to its context (e.g., URL encoding or base64url encoding for query or path parameters). Ensure that only safe URL protocols are permitted (e.g., disallow javascript: or data:).
Two problems live here. First, if you build a query string by gluing text together, an & inside the data starts a new parameter. Second, if you display a link a user gave you, javascript: and data: links run code when someone clicks them.
urls-bad.mjs glues the query string together, and checks links against a denylist:
function searchUrl (q) {
return `https://api.example.com/search?q=${q}&role=user`
}
function safeLink (raw) {
return raw.toLowerCase().startsWith('javascript:') ? null : raw
}
console.log('url :', searchUrl('shoes&role=admin'))
console.log('link :', safeLink('data:text/html,<script>alert(1)</script>'))
urls-good.mjs keeps the same two functions and the same calls. It just lets the built-in URL class do the work:
const ALLOWED = new Set(['http:', 'https:'])
function searchUrl (q) {
const url = new URL('https://api.example.com/search')
url.searchParams.set('q', q)
url.searchParams.set('role', 'user')
return url.toString()
}
function safeLink (raw) {
try {
const parsed = new URL(raw)
return ALLOWED.has(parsed.protocol) ? parsed.toString() : null
} catch {
return null
}
}
console.log('url :', searchUrl('shoes&role=admin'))
console.log('link :', safeLink('data:text/html,<script>alert(1)</script>'))
$ node urls-bad.mjs
url : https://api.example.com/search?q=shoes&role=admin&role=user
link : data:text/html,<script>alert(1)</script>
$ node urls-good.mjs
url : https://api.example.com/search?q=shoes%26role%3Dadmin&role=user
link : null
cmd/urls-bad/main.go glues the query string together, and checks links against a denylist:
func searchURL(q string) string {
return "https://api.example.com/search?q=" + q + "&role=user"
}
func safeLink(raw string) string {
if strings.HasPrefix(strings.ToLower(raw), "javascript:") {
return ""
}
return raw
}
cmd/urls-good/main.go keeps the same two functions and the same calls. It just lets net/url do the work:
var allowed = map[string]bool{"http": true, "https": true}
func searchURL(q string) string {
u, _ := url.Parse("https://api.example.com/search")
v := url.Values{}
v.Set("q", q)
v.Set("role", "user")
u.RawQuery = v.Encode()
return u.String()
}
func safeLink(raw string) string {
u, err := url.Parse(raw)
if err != nil || !allowed[u.Scheme] || u.Host == "" {
return ""
}
return u.String()
}
$ go run ./cmd/urls-bad
url : https://api.example.com/search?q=shoes&role=admin&role=user
link : "data:text/html,<script>alert(1)</script>"
$ go run ./cmd/urls-good
url : https://api.example.com/search?q=shoes%26role%3Dadmin&role=user
link : ""
url.Parse accepts almost anything, including text with no host in it, which is why the fixed version checks the host too.
Look at the broken URL closely. It has role twice: role=admin came from the user, role=user came from your code. Which one the receiving server uses is anyone’s guess, and plenty of them take the first. Someone just changed their own role through a search box. The fixed version turns the user’s & into %26, so the whole thing stays one value.
The broken link check fails for a different reason. It is a denylist. It blocks the one thing it was told about and waves through data:, protocol-relative links like //evil.example.net, and any other spelling a browser still accepts. The fix parses the link and checks its protocol against a short list of ones you allow.
Step 4: Encode Data Into JavaScript and JSON
V1.2.3 Verify that output encoding or escaping is used when dynamically building JavaScript content (including JSON), to avoid changing the message or document structure (to avoid JavaScript and JSON injection).
Say you want to hand some user data to your frontend, so you print it into a <script> block. There is a trap here that catches almost everyone. A browser ends a script block the moment it sees the characters </script>, wherever they appear. Even inside a quoted string. Even in valid JSON.
json-bad.mjs uses JSON.stringify, which most people assume is enough:
const bio = 'I love </script><script>alert(1)</script>'
function pageData (bio) {
return `<script>var user = ${JSON.stringify({ bio })}</script>`
}
console.log(pageData(bio))
json-good.mjs adds one small helper and calls that instead:
const bio = 'I love </script><script>alert(1)</script>'
function jsonForScript (value) {
return JSON.stringify(value).replace(/[<>&]|[^ -~]/g, (c) =>
'\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'))
}
function pageData (bio) {
return `<script>var user = ${jsonForScript({ bio })}</script>`
}
console.log(pageData(bio))
$ node json-bad.mjs
<script>var user = {"bio":"I love </script><script>alert(1)</script>"}</script>
$ node json-good.mjs
<script>var user = {"bio":"I love \u003c/script\u003e\u003cscript\u003ealert(1)\u003c/script\u003e"}</script>
The first output is perfectly valid JSON and still breaks the page wide open. The helper replaces <, >, &, and every character outside plain ASCII with a \u003c-style escape. JavaScript reads those escapes back as the original characters, so your data is unchanged, but the browser’s HTML parser never sees a </script>.
Escaping the non-ASCII characters matters too. Two of them, U+2028 and U+2029, count as line breaks in JavaScript but are perfectly legal inside a JSON string, so they can cut a statement in half.
cmd/json-bad/main.go builds the JSON by hand, which breaks on the first quote in the data:
func pageData(bio string) string {
return fmt.Sprintf(`<script>var user = {"bio":"%s"}</script>`, bio)
}
cmd/json-good/main.go hands the same job to the standard library:
func pageData(bio string) string {
b, _ := json.Marshal(map[string]string{"bio": bio})
return fmt.Sprintf(`<script>var user = %s</script>`, b)
}
$ go run ./cmd/json-bad
<script>var user = {"bio":"I love </script><script>alert(1)</script>"}</script>
$ go run ./cmd/json-good
<script>var user = {"bio":"I love \u003c/script\u003e\u003cscript\u003ealert(1)\u003c/script\u003e"}</script>
Go does the right thing here without being asked. encoding/json turns <, >, and & into \u003c, \u003e, and \u0026 by default, and handles U+2028 and U+2029 as well. Those two characters count as line breaks in JavaScript but are perfectly legal inside a JSON string, so they can cut a statement in half.
There is only one way to break this in Go: calling SetEscapeHTML(false) because the escapes looked ugly in a log. Do not.
There is an even better option. Do not put data inside executable script at all. Put it in a <script type="application/json" id="page-data"> block, then read it in the browser with JSON.parse(document.getElementById('page-data').textContent). The browser never treats that block as code, so the only thing left to escape is </script>.
Step 5: Send Values to the Database as Parameters
V1.2.4 Verify that data selection or database queries (e.g., SQL, HQL, NoSQL, Cypher) use parameterized queries, ORMs, entity frameworks, or are otherwise protected from SQL Injection and other database injection attacks. This is also relevant when writing stored procedures.
This is the classic one. If you build SQL by gluing strings together, a quote character in the data ends your string early, and everything after it is read as SQL. The attacker is now writing your query with you.
Make a throwaway database first:
sudo -u postgres createdb shop
sudo -u postgres psql -d shop -c "CREATE TABLE users (id serial primary key, email text, role text);
INSERT INTO users (email, role) VALUES ('[email protected]','user'),('[email protected]','user'),('[email protected]','admin');"
export DATABASE_URL="postgres://postgres@localhost/shop"
Both programs look up one email address. The address they are given is ' OR '1'='1.
sql-bad.mjs puts the email inside the query text:
async function findUser (email) {
const { rows } = await pool.query(
`SELECT email FROM users WHERE email = '${email}'`)
return rows
}
sql-good.mjs writes $1 where the value belongs, and passes the value alongside:
async function findUser (email) {
const { rows } = await pool.query(
'SELECT email FROM users WHERE email = $1', [email])
return rows
}
$ node sql-bad.mjs
[
{ email: '[email protected]' },
{ email: '[email protected]' },
{ email: '[email protected]' }
]
$ node sql-good.mjs
[]
cmd/sql-bad/main.go puts the email inside the query text:
func findUser(ctx context.Context, email string) []string {
rows, err := conn.Query(ctx, fmt.Sprintf(
"SELECT email FROM users WHERE email = '%s'", email))
if err != nil {
log.Fatal(err)
}
found, _ := pgx.CollectRows(rows, pgx.RowTo[string])
return found
}
cmd/sql-good/main.go writes $1 where the value belongs, and passes the value alongside:
func findUser(ctx context.Context, email string) []string {
rows, err := conn.Query(ctx,
"SELECT email FROM users WHERE email = $1", email)
if err != nil {
log.Fatal(err)
}
found, _ := pgx.CollectRows(rows, pgx.RowTo[string])
return found
}
$ go run ./cmd/sql-bad
[[email protected] [email protected] [email protected]]
$ go run ./cmd/sql-good
[]
The broken version returned every user in the table. A login form built on that query lets anyone in, usually as whoever is first in the list. The fixed version returns nothing, which is correct: no user has the email address ' OR '1'='1.
Here is why it works. With $1, the query text and the value travel to PostgreSQL separately. The database works out the shape of the query before it ever sees your data, so the data cannot change that shape. It is compared as a plain string.
One catch. Parameters work for values only, never for table or column names. So this is still broken, even if every other query in your app is fixed:
const column = req.query.sort ?? 'id'
await pool.query(`SELECT email FROM users ORDER BY ${column}`)
You cannot write ORDER BY $1. Use an allowlist that maps whatever the user sent to a name you wrote yourself:
const SORTABLE = { id: 'id', email: 'email', role: 'role' }
const column = SORTABLE[req.query.sort] ?? 'id'
await pool.query(`SELECT email FROM users ORDER BY ${column}`)
The same warning covers stored procedures: one that builds a query string inside itself and runs EXECUTE is injectable no matter how carefully you called it. It also covers the raw-SQL function your ORM offers for awkward cases. Connect Go to PostgreSQL with pgx and sqlc on Ubuntu shows a setup where the safe form is the only form.
Step 6: Run Commands Without a Shell
V1.2.5 Verify that the application protects against OS command injection and that operating system calls use parameterized OS queries or use contextual command line output encoding.
Calling another program is fine. The danger is asking a shell to read a string you built, because a shell treats ; as “and now run this next command”. That turns a filename into a command.
Both programs checksum an uploaded file. The filename is report.pdf; id.
mkdir -p uploads && echo hello > uploads/report.pdf
commands-bad.mjs uses exec, which runs your string through /bin/sh:
const { stdout } = await exec(`sha256sum uploads/${filename}`)
console.log(stdout.trim())
commands-good.mjs uses execFile, which takes the program and its arguments as separate items:
const { stdout } = await execFile('sha256sum', ['--', `uploads/${filename}`])
console.log(stdout.trim())
$ node commands-bad.mjs
5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03 uploads/report.pdf
uid=1000(deploy) gid=1000(deploy) groups=1000(deploy)
$ node commands-good.mjs
sha256sum: 'uploads/report.pdf; id': No such file or directory
cmd/commands-bad/main.go runs the string through sh -c:
func checksum(filename string) []byte {
out, _ := exec.Command("sh", "-c", "sha256sum uploads/"+filename).CombinedOutput()
return out
}
cmd/commands-good/main.go passes the program and its arguments as separate items:
func checksum(filename string) []byte {
out, _ := exec.Command("sha256sum", "--", "uploads/"+filename).CombinedOutput()
return out
}
$ go run ./cmd/commands-bad
5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03 uploads/report.pdf
uid=1000(deploy) gid=1000(deploy) groups=1000(deploy)
$ go run ./cmd/commands-good
sha256sum: 'uploads/report.pdf; id': No such file or directory
That uid=1000(deploy) line is the output of the id command. An attacker just ran a command on your server by naming a file. From there they can read files, open network connections, or install something.
In the fixed version no shell is involved. The operating system receives the program name and a list of arguments, and report.pdf; id is simply a strange filename that does not exist.
The -- matters as well. Arguments passed this way cannot become commands, but they can still become options if they start with -, and some tools have options that write files. -- tells the program that everything after it is a filename. Better still, check the filename yourself first: something like /^[a-zA-Z0-9._-]+$/, rejecting anything else.
Step 7: Sanitize HTML with a Real Parser
V1.3.1 Verify that all untrusted HTML input from WYSIWYG editors or similar is sanitized using a well-known and secure HTML sanitization library or framework feature.
Sometimes you have to keep the HTML. A comment box with bold and italic buttons produces real tags, and encoding them would just show the reader <b> instead of bold text. So you have to remove the dangerous parts and keep the rest.
Both programs clean the same input, which has three problems in it: an onclick handler, a <script> tag, and an image with an onerror handler.
sanitize-bad.mjs uses the regular expression almost everyone writes first:
function clean (html) {
return html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
}
sanitize-good.mjs uses a library that actually parses the HTML:
function clean (html) {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'b', 'i', 'strong', 'ul', 'li', 'br'],
ALLOWED_ATTR: ['title']
})
}
$ node sanitize-bad.mjs
<p onclick="alert(1)">Hi <b>there</b></p><img src=x onerror=alert(1)>
$ node sanitize-good.mjs
<p>Hi <b>there</b></p>
cmd/sanitize-bad/main.go uses the regular expression almost everyone writes first:
var scriptTag = regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`)
func clean(html string) string {
return scriptTag.ReplaceAllString(html, "")
}
cmd/sanitize-good/main.go uses a library that actually parses the HTML:
func clean(html string) string {
p := bluemonday.NewPolicy()
p.AllowElements("p", "b", "i", "strong", "ul", "li", "br")
p.AllowAttrs("title").OnElements("p")
return p.Sanitize(html)
}
$ go run ./cmd/sanitize-bad
<p onclick="alert(1)">Hi <b>there</b></p><img src=x onerror=alert(1)>
$ go run ./cmd/sanitize-good
<p>Hi <b>there</b></p>
The regex removed the one tag it knew about. It left behind an onclick that runs on any click, and an <img> whose broken src fires onerror straight away. Both run JavaScript, and neither needed a <script> tag.
Making the pattern longer does not save you. HTML has too many odd corners for a regular expression to keep up with, and the list of published bypasses for hand-written tag strippers is very long. Only a library that builds a real document tree and keeps an allowlist passes V1.3.1.
One more habit: clean the HTML when you display it, not when you save it. Sanitizers get security fixes. If you only stored the cleaned copy, every row you saved before the fix is still dangerous.
Step 8: Remove Dynamic Code Execution
V1.3.2 Verify that the application avoids the use of eval() or other dynamic code execution features such as Spring Expression Language (SpEL). Where there is no alternative, any user input being included must be sanitized before being executed.
This one usually arrives as a nice feature request. Admin-editable discount rules. Custom email templates. A formula field. Somewhere in the middle, text from your database gets run as code.
rules-bad.cjs runs the discount rule with eval:
function applyDiscount (rule, order) {
return eval(rule)
}
rules-good.cjs looks the rule up in a table of functions you wrote yourself:
const DISCOUNTS = {
bulk10: (order) => order.total * 0.10,
flat5: () => 5
}
function applyDiscount (rule, order) {
const fn = DISCOUNTS[rule]
return fn ? fn(order) : 0
}
Both are given a rule an attacker managed to save: require('node:child_process').execSync('id').toString().
$ node rules-bad.cjs
uid=1000(deploy) gid=1000(deploy) groups=1000(deploy)
$ node rules-good.cjs
0
20
The good version returns 0 for the attack, then 20 for the real rule bulk10 on an order of 200. The rule name now chooses a function instead of becoming one.
Treat new Function, vm.runInNewContext, and setTimeout with a string argument the same way. Then switch on the ESLint rules no-eval, no-implied-eval, and no-new-func so nobody adds them back later.
Go has no eval, but it has the same problem in a different shape: a template whose text comes from a user. cmd/rules-bad/main.go hands the template the whole account:
type Account struct{ Name string }
func (a Account) APIKey() string { return "sk_live_9f3a2b7c" }
func renderMail(tmpl string, acct Account) {
t := template.Must(template.New("mail").Parse(tmpl))
t.Execute(os.Stdout, acct)
}
cmd/rules-good/main.go hands it a map holding only the fields the template is allowed to use:
func renderMail(tmpl string, acct Account) {
t := template.Must(template.New("mail").Parse(tmpl))
t.Execute(os.Stdout, map[string]string{"Name": acct.Name})
}
Both render the same user-written template: Hi {{.Name}}, your key is {{.APIKey}}.
$ go run ./cmd/rules-bad
Hi Ana, your key is sk_live_9f3a2b7c
$ go run ./cmd/rules-good
Hi Ana, your key is <no value>
Whoever wrote that template was only supposed to use the name. Because the first version passed the whole Account, the template could call any exported method on it, including the one that returns the API key. This is called server-side template injection.
The map fixes this case, because a template can only reach keys that are actually in it. The stronger rule is simpler: do not build templates out of text stored in your database or sent in a request. If users need custom messages, swap placeholders yourself over a fixed set of allowed names.
Step 9: Lock Down the XML Parser
V1.5.1 Verify that the application configures XML parsers to use a restrictive configuration and that unsafe features such as resolving external entities are disabled to prevent XML eXternal Entity (XXE) attacks.
An XML document can define its own shortcuts, called entities. &lol; might be defined at the top to mean haha. That sounds harmless until you learn an entity can also point at a file on your server, or at a URL. A parser that follows those pointers will read the file and hand you the contents, or make the request on the attacker’s behalf. That attack is called XXE.
xml-bad.mjs builds the parser with no options, so it accepts whatever the document declares:
const parser = new XMLParser()
xml-good.mjs turns entity handling off:
const parser = new XMLParser({ processEntities: false })
Both parse a document that declares <!ENTITY lol "haha"> and then uses &lol;.
$ node xml-bad.mjs
{"note":"haha"}
$ node xml-good.mjs
{"note":"&lol;"}
Be clear about what you are seeing. fast-xml-parser refuses entities that point at files or URLs no matter how you configure it, so the file-reading attack does not work here at all. What the default does allow is entities that expand into other entities, which is how an attacker turns a small upload into gigabytes of memory. processEntities: false closes that, and leaves &lol; as plain text.
Go’s encoding/xml never follows entity definitions, whether they point at a file or not. Give it the XXE document and it stops at the parse step:
$ go run ./cmd/xml-good
note="" err=XML syntax error on line 2: invalid character entity &xxe;
There is genuinely nothing to fix here, so there is no broken version to show you. What is worth adding is a note in the code saying so, because a future reader cannot see a default:
func decode(payload string) (Invoice, error) {
var inv Invoice
dec := xml.NewDecoder(strings.NewReader(payload))
dec.Strict = true
dec.Entity = map[string]string{}
return inv, dec.Decode(&inv)
}
Those two lines change no behaviour at all. They write the guarantee down in your code, so that swapping in a third-party XML library later becomes a visible change rather than a silent one.
Both stacks are safe by default here, which is exactly why this requirement is easy to fail: the risk lives in a parser you did not pick. Older Node bindings to libxml have a noent option that switches entity resolution back on, SOAP and SAML libraries often bundle their own parser, and Java and PHP parsers are unsafe out of the box. Test it rather than assume it, and keep the XXE document as a permanent test case.
Common Mistakes and Troubleshooting
Cleaning input at the front door instead of encoding at the exit. It feels tidy, and within a month you have &amp; in your database with no way to tell which layer put it there. It also does nothing for CSV exports, email subjects, or log files, which all need different treatment.
Blocking the attack you saw instead of allowing the input you want. Stripping <script>, or rejecting links that begin with javascript:, closes one spelling of an attack that has many. Every fix in this article is an allowlist for that reason.
Assuming JSON.stringify is safe inside a <script>. It produces correct JSON that still ends the block early. This is the most common way a server-rendered page gets XSS today.
Fixing the values in a query but not the column names. ORDER BY and table names cannot use parameters. If you are reaching for string building in a query, that piece needs an allowlist.
Switching to execFile but letting the user pick the program. Argument lists stop shell tricks. They do nothing if the attacker chooses which program runs. The program name must be a fixed string in your code.
Cleaning HTML when saving instead of when displaying. When your sanitizer publishes a fix, cleaning at display time protects the rows you already have. Cleaning at save time does not.
Best Practices
Make the safe way the only way. Write one db.query(sql, params) helper and have it refuse anything suspicious. Write one runTool(name, args) helper that picks the program from a fixed map. That catches more bugs than any code review will.
Keep these payloads as tests. ' OR '1'='1, report.pdf; id, </script><script>alert(1)</script>, data:text/html,<script>alert(1)</script>, and the XXE document are five quick assertions. They will tell you the day somebody deletes a control by accident.
Add a Content Security Policy, but do not lean on it. A policy without unsafe-inline turns many XSS bugs into harmless console errors. It is a second layer, not a replacement. ASVS puts it in a different chapter for that reason.
Prefer tools that are safe when you forget. html/template, encoding/json, sqlc, and execFile all do the right thing by default. Anything that depends on a developer remembering a flag will eventually be forgotten.
Conclusion
You have worked through all eight Level 1 requirements in V1, and seen the broken version of each one next to its fix: raw output against encoded output, glued-together URLs against a URL builder, JSON.stringify against script-safe encoding, string-built SQL against parameters, a shell against an argument list, a regex against a real HTML parser, eval against a lookup table.
In every pair the two files are nearly identical. That is the point. These are not exotic bugs. They are one line of ordinary-looking code.
Mark V1.2.1 through V1.5.1 as passed in your own record, and note the file and line where each fix lives. “Passed” on its own is worth very little six months from now.
The next part covers V2 Validation and Business Logic and its four Level 1 requirements: checking input against rules you define, why checking in the browser does not count, making multi-step flows happen in the right order, and the first of the four documents this series produces.
The OWASP Application Security Verification Standard is licensed under Creative Commons Attribution-ShareAlike 4.0, which is what permits the requirement text to be reproduced here.