Security
Photon handles the HTTP-shaped attacks for you, with limits that are on from
the first photon.New(): slow clients, header floods, oversized bodies, route
ambiguity, reflected error content, SSE injection, and unbounded stream
memory. It does not know who your users are, what they may do, or what your
model is allowed to touch. This guide covers both halves: what is on by
default and the attack each default stops, then what remains your job.
- On by default
- Slow clients
- Header floods
- Oversized bodies
- Error responses never reflect the request
- The client address is the socket peer
- Routing cannot be bypassed with encodings or ambiguity
- Panics do not leak
- TLS defaults in ListenTLS
- DecodeJSON refuses non-JSON bodies
- SSE field injection is impossible
- Stream memory is bounded
- Off by default: connection and concurrency caps
- What is your job
- Hardening checklist for production
- Reporting a vulnerability
On by default
Section titled “On by default”Every limit below is a field of photon.Limits with a safe default. The
configuration guide lists them all with
advice on when to change them.
Slow clients
Section titled “Slow clients”Slowloris opens connections and sends request headers one byte at a time,
holding a goroutine and a file descriptor per connection for as long as it
likes. ReadHeaderTimeout (5 s) closes a connection whose headers have not
arrived in time.
Idle keep-alive connections are closed after IdleTimeout (60 s), so an
attacker, or a buggy client pool, cannot hold descriptors open indefinitely.
ReadTimeout, a deadline on the whole request, is deliberately zero. It is
absolute, so it would cut off a five-minute streamed answer however healthy
the connection is. Streams are protected by StreamStallTimeout instead; see
below.
Header floods
Section titled “Header floods”MaxHeaderBytes(16 KiB) bounds the total header size.net/httpenforces it from the first byte it reads and answers 431 itself. Its own default is 1 MiB, which across 10,000 connections is 10 GB of header buffer.MaxHeaders(100) bounds the number of header fields, a limitnet/httphas no concept of. Thousands of tiny headers fit under any byte limit while costing real CPU to parse, hash, and store. Photon counts fields, not distinct names: a request with 1,000 copies ofX-A: 1is refused, where a check oflen(r.Header)would see one name and let it through.MaxHeaderValueBytes(8 KiB) bounds any single value, so one enormousCookieorAuthorizationheader cannot take most of the budget.
Refusals are a 431 problem document naming the limit:
{"type":"https://photon.agenticmarket.dev/errors/too_many_headers","title":"the request has too many header fields","status":431,"code":"too_many_headers","limit":"MaxHeaders"}Oversized bodies
Section titled “Oversized bodies”MaxRequestBodyBytes (1 MiB) is enforced in two ways:
- When
Content-Lengthdeclares a larger body, photon answers 413 before reading a byte of it, and closes the connection. - When the length is unknown (chunked uploads),
r.Bodyis wrapped inhttp.MaxBytesReader. A read past the limit fails with*http.MaxBytesError,net/httpstops reusing the connection, andphoton.DecodeJSONreports it as a 413 rather than as malformed JSON.
The limit is a rejection threshold, not a memory cost. A body is read as a stream, so it bounds how much work one request can ask for.
Error responses never reflect the request
Section titled “Error responses never reflect the request”A response that echoes a path, header, or body back is a route-enumeration oracle and, if a browser can be persuaded to sniff it as HTML, an XSS vector. Photon never does it:
- 404 and 405 bodies are fixed strings.
- Limit refusals are RFC 9457 problem documents that name the limit
(
MaxHeaders), which is configuration, not request data. A too-large header is named; its value is never echoed. photon.Errorrenders a*photonerr.Errorwith itsMessage, which you write, and never itsDetailor wrapped cause, which are for your logs. Any other error becomes a 500 with a constant body, because an arbitrary error’s text can contain a connection string or an internal hostname.photon.DecodeJSONerrors never quote the body. A field name appears only when it is a plain identifier.photon.JSONescapes<,>, and&, so a string containing<script>is not a reflected-XSS vector.
Every response photon writes itself (JSON, Text, HTML, problem documents,
the 404/405/500 bodies) carries X-Content-Type-Options: nosniff, so a browser
will not reinterpret it as another content type.
The client address is the socket peer
Section titled “The client address is the socket peer”photon.ClientIP(r) returns the address of the immediate peer and ignores
X-Forwarded-For, X-Real-IP, and Forwarded. Those headers are written by
the client. Trusting them by default lets anyone forge their address and
defeat rate limiting, access logging, and IP allowlists. MaxPerIP is
enforced on the socket peer for the same reason.
Behind a proxy this means every request appears to come from the proxy. See Trusted proxies for how to handle that safely.
Routing cannot be bypassed with encodings or ambiguity
Section titled “Routing cannot be bypassed with encodings or ambiguity”- Routing uses the raw path.
/admin%2Fsettingsis one segment and cannot reach/admin/:page. If%2Fwere decoded before routing, an encoded slash could cross a segment boundary that a proxy rule or an auth check treated as fixed. - Ambiguous routes are refused at startup. Two routes that could both match a request panic during registration, so which handler serves a request (and which middleware protects it) never depends on registration order or spelling. See routing.
- An encoding cannot walk around a route to a fallback. A mount, or a
custom
NotFound, may hand a request to code that reads the decoded path - a legacy router, a file server. So a request whose raw path misses every route but whose decoded path would match one (/admin%2Fsecret,/%61dmin/secret) is refused with a 400 instead of reaching the fallback. HEADis not derived fromGET, so a handler never answers a method it was not written for.
Panics do not leak
Section titled “Panics do not leak”A panicking handler gets a 500 internal server error with a constant body.
The panic value can contain anything the handler had, including secrets, so it
goes to your logger and your OnPanic hook, never to the client. A panic
after a stream has started aborts the connection, so the client sees a failure
instead of a truncated answer that looks complete.
TLS defaults in ListenTLS
Section titled “TLS defaults in ListenTLS”ListenTLS sets TLS 1.2 as the minimum version, negotiates HTTP/2, and does
not enable 0-RTT early data: early data is replayable by definition
(RFC 8470), and a replayable request is worse than a slow one. For certificate
reloading or ACME, see deployment.
DecodeJSON refuses non-JSON bodies
Section titled “DecodeJSON refuses non-JSON bodies”photon.DecodeJSON answers 415 when the Content-Type is something other than
application/json or a +json type. This is a CSRF defence: a cross-site HTML
form can POST text/plain or form-encoded bodies without a CORS preflight,
but not application/json.
It is not a complete defence. A request with no Content-Type is
accepted, and a cross-site page can send one (for example, fetch with a
Blob or ArrayBuffer body). If you authenticate with cookies, also use
SameSite cookies and check Origin, or use CSRF tokens. Bearer tokens in an
Authorization header are not sent automatically by browsers, so they are not
exposed to CSRF.
DecodeJSON also refuses trailing data, so {"a":1}{"a":2} cannot smuggle a
second object past a check that looked only at the first.
SSE field injection is impossible
Section titled “SSE field injection is impossible”SSE is a line-based format. If a newline from user input or model output
reached the wire unescaped, it could end a field early and start a new one,
injecting an event, changing an id, or setting retry.
- Event names and ids containing CR, LF, or NUL are refused:
Event,JSON, andSetEventIDreturnphoton.ErrInvalidEventand write nothing. - Event data is split on CRLF, LF, and a lone CR exactly as the client splits
it, and every line becomes its own
data:field. The client reassembles the payload with\n, so line breaks survive and nothing in the payload can start a new field. Commentreplaces line breaks with spaces.
This is pinned by a fuzz test, FuzzSSEEventRoundTrip.
Stream memory is bounded
Section titled “Stream memory is bounded”A slow or stalled client cannot make a stream grow without limit:
MaxStreamsbounds concurrent streams. When the server is full, a new stream gets a 503 withRetry-Afterbefore any buffer is allocated. The default is derived from the memory budget.MaxStreamBufferBytes(64 KiB) caps the unsent bytes per stream. When it is full, the producer waits; a slow client produces a slow producer, not a growing buffer.StreamStallTimeout(30 s) ends a stream whose client accepts nothing for that long, releasing its goroutine, buffer, and slot.
See streaming for the overflow policies.
Off by default: connection and concurrency caps
Section titled “Off by default: connection and concurrency caps”MaxConnections, MaxConcurrentRequests, and MaxPerIP are zero (unlimited)
by default, because the right numbers depend on your machine and your
traffic. When set, connections over the limit are refused before a byte of the
request is read, and requests over MaxConcurrentRequests get a 503 with
Retry-After. Set them for production; the
configuration guide explains how they
interact with streams and proxies.
A connection that is hijacked - a WebSocket upgrade - leaves this accounting when it is hijacked, because nothing reports when it later closes. Bound upgraded connections in the upgrade handler, for example with a semaphore.
What is your job
Section titled “What is your job”Authentication and authorization
Section titled “Authentication and authorization”Photon has no notion of users. Put authentication in a Group so a route
cannot forget it, and test it with one request per protected route. The
middleware guide has a complete
bearer-token example.
Authorization is separate and per resource. A route like
/v1/conversations/:id must check that the conversation belongs to the
caller; authenticating the caller is not enough. Missing object-level checks
are the most common API vulnerability.
CORS, CSRF, and browser-facing responses
Section titled “CORS, CSRF, and browser-facing responses”- CORS: use an allowlist of origins. Never reflect an arbitrary
Originwith credentials allowed. See CORS. - CSRF: relevant if you authenticate with cookies. See DecodeJSON for what photon covers and what it does not.
- Headers for HTML:
photon.HTMLsetsnosniffbut no Content Security Policy, because a default CSP that is wrong for your app is worse than none. Set CSP,Strict-Transport-Security, and frame protection yourself, usually at the proxy. - Redirects:
photon.Redirect(w, r, url)redirects wherever you tell it. Never pass a URL taken from the request without checking it against an allowlist, or it is an open redirect.
Rate limits and quotas
Section titled “Rate limits and quotas”Photon bounds resources (connections, streams, memory). It does not limit how often a client may call you, or how many tokens a user may spend. Add a rate limiter (example) and, for AI endpoints, per-principal quotas on tokens and concurrent streams.
Trusted proxies and the client address
Section titled “Trusted proxies and the client address”Behind a load balancer, photon.ClientIP returns the load balancer’s address.
To recover the real client, trust X-Forwarded-For only when the connection
comes from your own proxy, and read it from the right:
package main
import ( "net/http" "net/netip" "strings"
"github.com/agenticmarket/photon")
// trustedProxies are the networks your own load balancers connect from.var trustedProxies = []netip.Prefix{ netip.MustParsePrefix("10.0.0.0/8"),}
func trusted(ip netip.Addr) bool { for _, p := range trustedProxies { if p.Contains(ip) { return true } } return false}
// realClientIP returns the client address as seen by your outermost trusted// proxy. X-Forwarded-For is read right to left, through trusted hops only: the// first address that is not a trusted proxy is the client. Anything to its// left was written by the client and is ignored.func realClientIP(r *http.Request) netip.Addr { ip := photon.ClientIP(r).Unmap() if !trusted(ip) { return ip // a direct connection: any forwarded header is the client's invention } hops := strings.Split(strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",") for i := len(hops) - 1; i >= 0; i-- { hop, err := netip.ParseAddr(strings.TrimSpace(hops[i])) if err != nil { return ip // malformed or missing: fall back to the last trusted hop } hop = hop.Unmap() if !trusted(hop) { return hop } ip = hop } return ip}Use realClientIP wherever you would have used photon.ClientIP: rate-limit
keys, audit logs, allowlists. Rules that keep this safe:
- The proxy must append to
X-Forwarded-For(nginx’s$proxy_add_x_forwarded_for, AWS ALB’s default), so the rightmost entry is the address it actually saw. - Never take the leftmost entry. The client wrote it.
- List only networks you control in
trustedProxies. With a CDN in front, trust its published ranges and prefer its own header (for example, Cloudflare’sCF-Connecting-IP) when the peer is in those ranges. MaxPerIPcounts the socket peer, which behind a proxy is the proxy. Do not set it in that deployment.
TLS termination and certificate rotation
Section titled “TLS termination and certificate rotation”Terminate TLS at your proxy or load balancer, or in photon with ListenTLS or
Serve with your own tls.Config. Photon does not obtain or renew
certificates. Deployment shows certificate reloading and
ACME.
Secrets and logs
Section titled “Secrets and logs”- Load API keys and model-provider credentials from the environment or a secret store, never from source.
- Treat your logs as sensitive. Photon logs panic values and stack traces, and
the full cause of every 5xx written with
photon.Error, so an operator can see what the client cannot. Those can contain whatever your code had in hand. - Do not log
Authorizationheaders, cookies, or prompts that may contain personal data unless you have decided to and protect the logs accordingly. photonerr.RenderOptions{IncludeDetail: true}sendsDetailto clients. It is off by default; leave it off in production.
Files and paths
Section titled “Files and paths”Photon does not clean request paths. /files/../etc/passwd reaches
/files/*path with path set to ../etc/passwd, and an encoded
%2e%2e arrives still encoded. Never join a path parameter onto a filesystem
path yourself. Use os.OpenRoot (Go 1.24) and open files through the
*os.Root, which refuses to escape its directory, or serve an fs.FS with
http.FileServerFS.
AI-specific risks
Section titled “AI-specific risks”Keep these short rules in mind; each has caused real incidents.
-
Model output is untrusted input. Render it as text (
textContent, or a Markdown renderer with raw HTML disabled), never as HTML. Never execute it, and never interpolate it into SQL, shell commands, or file paths. Check the scheme of any URL it produces (httpsonly) before linking to or fetching it. -
Tool URLs are an SSRF vector. A model asked to “fetch this page” can be steered to
http://169.254.169.254/(cloud metadata) or your internal services. Give tool calls an HTTP client that refuses non-public addresses, checked after DNS resolution:// publicOnly refuses loopback, private, link-local (which includes cloud// metadata), multicast, and unspecified addresses. It runs on the address// actually dialled, so a hostname resolving to an internal address is// refused too, on every redirect.func publicOnly(network, address string, _ syscall.RawConn) error {host, _, err := net.SplitHostPort(address)if err != nil {return err}ip, err := netip.ParseAddr(host)if err != nil {return err}if ip = ip.Unmap(); !ip.IsGlobalUnicast() || ip.IsPrivate() {return fmt.Errorf("refusing to connect to non-public address %s", ip)}return nil}var toolClient = &http.Client{Timeout: 30 * time.Second,Transport: &http.Transport{// No Proxy: a proxy would do the dialling and bypass the check.DialContext: (&net.Dialer{Timeout: 10 * time.Second, Control: publicOnly}).DialContext,},}Add your own VPC ranges or carrier-grade NAT space (
100.64.0.0/10) if they are reachable from the server. -
Cap agent loops. Bound the number of tool calls per request, the tokens per request and per user, and the wall-clock time with
context.WithTimeout(r.Context(), ...). Passr.Context()to every model and tool call so a client that leaves stops the work. -
Assume prompt injection. Retrieved documents, web pages, and tool results can contain instructions. Tools should act with the end user’s permissions, not the server’s; side-effecting tools (sending email, spending money, deleting data) should require explicit user confirmation; and secrets should never be placed in a prompt, because the model can be talked into repeating them.
See Building AI backends for the surrounding patterns.
Hardening checklist for production
Section titled “Hardening checklist for production”- TLS everywhere, terminated at a proxy or with
ListenTLS; HSTS at the edge. -
GOMEMLIMITorphoton.MemoryBudgetset explicitly;app.Limits()logged at startup. -
MaxConcurrentRequestsandMaxConnectionssized for the machine;MaxPerIPonly if clients connect directly. -
photon.Loggerconfigured, with alerts on panics and 5xx; 413, 431, 429, and 503 rates on a dashboard. - Authentication applied through groups, with one behavioural test per protected route.
- Object-level authorization on every route that takes a resource id.
- CORS allowlist; no reflected origins.
- Rate limits, plus token and stream quotas per principal.
- Real client IP derived from trusted proxies only.
- Container runs as non-root, with a read-only root filesystem and no shell.
- pprof and admin endpoints on a separate, non-public listener.
- Secrets from the environment or a secret store; logs treated as sensitive.
- Outbound HTTP for tools goes through an SSRF-safe client with timeouts.
-
govulncheck ./...in CI, and Go kept current. Photon has no dependencies, so most fixes that matter (innet/httpandcrypto/tls) arrive with Go releases.
Reporting a vulnerability
Section titled “Reporting a vulnerability”Please do not open a public issue for a security problem. Follow the process in SECURITY.md.
See also
Section titled “See also”- Configuration: every limit, its default, and when to change it
- Middleware: auth, CORS, and rate-limiting examples
- Deployment: proxies, TLS, and containers
- Routing: conflict rules and raw-path routing
- Streaming
- Building AI backends
- Security architecture and threat model