Go security: five stdlib boundaries still fail (2026)

Five garden gates. The last hangs ajar.

CVE-2026-32289 landed on 7 April 2026 in html/template. GO-2026-4865 said context was not tracked across template branches for JavaScript template literals, so an action could get the wrong escaper, and Go listed XSS as the result. Fixed in go1.25.9 and go1.26.2. On 13 August 2026, GO-2026-6091 (CVE-2026-56858) did it again: a pathological input could close an unescaped slash early. Fixed in go1.25.13 and go1.26.6. The package still encodes by context. The toolchain still has to be current.

Those five jobs are still yours in 2026. When the sink is the browser, read XSS. When the sink is an interpreter, read injection.

html/template still needs a current toolchain

html/template is the stdlib answer to contextual encoding. It tracks whether {{ .Comment }} sits in an HTML body, an attribute, a script string, a CSS value, or a URL, and it picks the escaper for that context. text/template does none of that. fmt.Fprintf into a tag does none of that. If the response is HTML, the execute call has to go through html/template.

The same request fields still hit five stdlib sinks. Each tile is the API that owns that context, not a checklist paragraph.

SecureCoding

I opened the pkg.go.dev pages for GO-2026-4865 and GO-2026-6091. Both name html/template. Both list XSS. Both were fixed by bumping the minor. That is the control: run a current toolchain, then keep the execute path on html/template.

// FIX: html/template encodes Comment for the surrounding context
tmpl := template.Must(template.New("page").Parse(`
<article><p>{{ .Comment }}</p></article>`))
err := tmpl.Execute(w, page)
if err != nil {
    http.Error(w, "render failed", http.StatusInternalServerError)
    return
}

Three ways teams give the encoding back:

  • text/template for an HTML response. Same actions, no HTML escaper. Swap the import. The parse string can stay.
  • A template.HTML cast on a request field. That type is a trust marker. It belongs on a string you minted, such as a CSP nonce you just generated. It does not belong on r.FormValue("comment").
  • String-building a <script> block, then executing around it. Context tracking cannot see a context you never handed it. Put the data in a JSON script type and read it from a static module, or keep the action inside a context the package already tracks.

A comment that contains < is valid input. Encoding happens at render, for that sink. Type and length checks still belong on the way in. They do not replace the execute call.

I also opened GO-2026-4980 (published 7 May 2026). If a trusted template author wrote a tag with an empty type attribute, or a type that was only ASCII whitespace, the escaper applied the wrong rule to the block. Fixed in go1.25.10 and go1.26.3. The pattern across April, May, and August 2026 is the same: context tracking missed a JavaScript or type edge, the package applied the wrong escaper, Go called it XSS. Your job is the current minor, plus refusing template.HTML on request data. You cannot patch a context bug by switching to text/template.

database/sql: bind the value, map the identifier

database/sql sends the statement and the values on two channels when you pass placeholders. Postgres style is $1. MySQL and SQLite style is ?. The driver documents which one you have. The value in email never becomes grammar.

const stmt = `SELECT id, name FROM accounts WHERE org_id = $1 AND email = $2`

rows, err := db.QueryContext(ctx, stmt, actor.OrgID, email)
if err != nil {
    return err
}
defer rows.Close()

An identifier is not a bind. ORDER BY $1 does not make a column name safe. A REST _sort=created looks like a value. The engine treats it as grammar. Map the token to a column you wrote.

var orderBy = map[string]string{
    "created": "created_at",
    "name":    "name",
}

col, ok := orderBy[sortKey]
if !ok {
    col = "created_at"
}
q := `SELECT id, name FROM accounts WHERE org_id = $1 ORDER BY ` + col + ` ASC`
rows, err := db.QueryContext(ctx, q, actor.OrgID)

col is a string you put in the map. sortKey never reaches SQL. The same map works for a direction token (asc / desc). Grep for fmt.Sprintf around Query, for + on a query string, and for any helper named Raw. Those are the hatches. The injection page is the longer treatment of interpreters. This page is the Go call shape.

exec.Cmd: Args are already split

os/exec.Command takes a binary and a list. Cmd.Args is that list. The operating system receives them as separate arguments. No shell. No metacharacter grammar. That is the whole control.

// BAD: one string, shell grammar, interpolation
// cmd := exec.Command("sh", "-c", "convert "+userName)

// FIX: binary and Args stay apart. userName is one argument.
cmd := exec.Command("convert", "--", userName)
cmd.Stdout = &out
cmd.Stderr = &errBuf
err := cmd.Run()

-- stops a leading-dash userName from being read as a flag. That is argument injection, not shell injection. Both start with a string you should have left in Args.

Three greps that still pay:

  • exec.Command("sh" and exec.Command("bash". A hit is a review. If you truly need a shell, the script is a file you wrote, and the user data is a later Args entry, never spliced into the script text.
  • fmt.Sprintf feeding Command. Same review.
  • CombinedOutput on a helper that still builds one line. Read the helper. The list has to stay a list through the last call.

Go will not save a C helper you then start with a glued line. The boundary is the first process you spawn. If the binary is convert and the file is userName, the kernel sees two strings. If the binary is sh and the next argument is a line you built with +, the kernel sees a shell, and the shell sees grammar. That is the only distinction that matters. CombinedOutput and Output do not change it.

LookPath is for finding the binary on PATH. Do not take userName as the first argument to Command. The first argument is a binary you chose. The rest is data.

path.Clean is not a sandbox

path.Clean is a lexical shortest-equivalent helper for slash-separated strings. filepath.Clean is the OS-aware twin. Neither one pins a result under a directory. Neither one refuses a relative climb. Neither one sees a symlink. The name sounds like a sanitizer. The docs describe a normalizer.

Go 1.24.0 shipped on 11 February 2025 with os.Root and os.OpenInRoot. The Go blog post on traversal-resistant file APIs is the first-party writeup. A Root is a directory handle. Methods on it are supposed to refuse a path that would leave that directory. That is the sandbox. Clean is not. Two 2026 escapes show it is not a frozen proof: CVE-2026-27139 let ReadDir leak metadata outside the root (fixed go1.25.8 and go1.26.1). CVE-2026-39822 followed a symlink plus a trailing slash (fixed go1.25.12 and go1.26.5). Go 1.27.0 shipped on 19 August 2026. Stay on 1.25.12+, 1.26.5+, or 1.27.

// BAD: lexical tidy, then a join you hope stays put
// cleaned := path.Clean(userPath)
// f, err := os.Open(dataDir + "/" + cleaned)

// FIX: OpenInRoot on go1.25.12+, go1.26.5+, or go1.27. userPath stays under dataDir.
f, err := os.OpenInRoot(dataDir, userPath)
if err != nil {
    return err
}
defer f.Close()

For more than one operation, open the root once and reuse it:

root, err := os.OpenRoot(dataDir)
if err != nil {
    return err
}
defer root.Close()

f, err := root.Open(userPath)
if err != nil {
    return err
}
defer f.Close()

Do not pass an untrusted string to os.OpenRoot itself. The root is the directory you chose. The untrusted name is the argument to Open. Prefix checks after Clean fail on a missing trailing separator (/var/data is a prefix of /var/data-extra) and they lose a symlink race. filepath.IsLocal (Go 1.20) and filepath.Localize (Go 1.23) are lexical filters. The official blog says they are enough only when the threat model does not include an attacker who can plant a symlink. os.Root is the API that closed that case. The path traversal guide is the longer treatment. This page is the Go call that replaced the prefix dance.

Integer overflow sits in front of make and copy

Go’s runtime rejects a negative or overflowing size inside make. I opened src/runtime/slice.go on the go1.24.0 tag: makeslicecopy multiplies element width by length with math.MulUintptr and panics on overflow. That is not your control. Your control is the addition you run before that call.

need := headerN + userN wraps on a 64-bit int when userN is near math.MaxInt. The wrapped need can look small. make([]byte, need) then succeeds. copy writes min(len(dst), len(src)) bytes. It will not write past dst. It will happily fill a dst you sized from a wrapped need. The bug is the arithmetic, not the builtin.

const maxBody = 1 << 20 // 1 MiB
const headerN = 32

if userN < 0 || userN > maxBody || headerN > math.MaxInt-userN {
    return errTooLarge
}
need := headerN + userN
buf := make([]byte, need)
n := copy(buf[headerN:], src)
if n != userN {
    return errShort
}

userN is the count you parsed. Reject it before you add. Cap it against a real maximum, not against “whatever the header claimed.” Content-Length is an untrusted integer. strconv.Atoi can return a negative. int(uint64Value) can truncate. The integer overflow guide is the language-agnostic version of this check.

copy is the right builtin once need is trusted. Do not switch to a loop that indexes buf[i] from a second untrusted length. One checked size, one destination, one copy.

The same check belongs in front of io.ReadFull and io.CopyN when the count came from a header. http.MaxBytesReader(w, r.Body, maxBody) is the stdlib cap for a request body. Use it, then io.ReadAll on that limited reader, then check len. Do not make([]byte, r.ContentLength) and trust the client to have told the truth.

Grep and tests you can run today

You are not walking an exploit. You are proving the five call shapes still hold in your tree.

  1. Print the toolchain. Expect go1.25.13, go1.26.6, or go1.27.0. Those close the April and August 2026 html/template advisories. os.Root needs 1.25.12 or 1.26.5 if you are still on those lines. Then run govulncheck ./....
  2. Grep text/template in packages that write text/html. Grep template.HTML( and read every hit. Grep fmt.Sprintf next to Query, QueryContext, Exec, and ExecContext.
  3. Grep exec.Command("sh", exec.Command("bash", and CommandContext with a single concatenated string. Grep path.Clean and filepath.Clean next to os.Open, os.Create, and os.WriteFile.
  4. Grep make([]byte and make([] where the length is a request field or a header-derived int. Every hit needs the overflow check above, or a LimitReader with a constant cap.
go version
# Expect: go1.27.0, or go1.26.6+, or go1.25.13+

govulncheck ./...

rg -n 'text/template|template\.HTML\(|exec\.Command\("sh"|exec\.Command\("bash"|path\.Clean|filepath\.Clean' --glob '*.go'

A unit test for the size check is cheap. Feed userN = math.MaxInt and expect errTooLarge. Feed a sort key that is not in the map and expect the default column, not a 500. Feed a name that is only a file name to OpenInRoot and expect a successful open under dataDir. You are asserting your reject path, not someone else’s host.

SinkAPI that owns itGrep the hatch
HTMLhtml/template.Executetext/template, template.HTML
SQL valueQuery(stmt, email)fmt.Sprintf + Query
SQL nametoken map to a literalORDER BY + variable
ProcessCommand(bin, name)sh -c, glued Args
FileOpenInRoot(dir, name)path.Clean then Open
Bufferchecked need, then copymake([]byte, x+y)

Questions we keep getting

Is Go memory-safe enough that this page is leftover advice?

Memory safety stops a class of spatial bugs in your own slices. It does not pick an HTML escaper, it does not bind a query, it does not split a shell line, it does not jail a path, and it does not check the integer you computed for make. The five APIs above are still the controls.

Can I keep path.Clean if I also check a prefix?

A prefix check is the dance os.Root replaced. It fails on a missing trailing separator and it loses a symlink race. If you are on go1.25.12+, go1.26.5+, or go1.27, use OpenInRoot. Go 1.24 is past its patch window. Upgrade, then use it. Clean is still fine as a cost limiter before you hand a name to Root. It is not the jail.

Does html/template mean I can ignore CSP and Trusted Types?

No. Contextual encoding is the server-side half. A later innerHTML assignment, a JSON endpoint consumed by a page that writes strings into the DOM, or a template.HTML cast still opens XSS. Keep the toolchain current so the escaper you chose is the one the advisory just fixed.

Guy Bar-Gil

Guy Bar-Gil / About Author

Guy is a product manager at WhiteSource, where we enable software development teams to integrate open source fearlessly and without compromising agility. Before WhiteSource, Guy worked for the IDF's intelligence division, where he spent time as a combat operator and project manager. Outside of work, you can find Guy reading (everything from fiction to physics), playing and watching sports, traveling the world, and spending time with friends and family. LinkedIn