Safe Defaults
Muzak starts from settings that are safe rather than permissive. Each of them can be relaxed; none of them is relaxed by omission.
The list
| Area | Default | Relax it with |
|---|---|---|
| Listener timeouts | All four are non-zero. An http.Server left at its zero values holds a connection open forever, which is all a slow-loris client needs. Over HTTP/2, WriteTimeout also closes a connection whose socket accepts no bytes for that long | ServerOptions, a negative value to disable one |
| Request bodies | Capped at 1 MiB. A body that declares a Content-Length over the limit is refused before any of it is read | AppOptions.MaxBodySize, MaxBodySize(n) |
| Form bodies | A route that declares a file field is capped at 32 MiB. A route that binds only form values is capped at 1 MiB, urlencoded or multipart | AppOptions.MaxUploadSize, MaxUploadSize(n), MaxBodySize(n) |
| Request headers | The header block is capped at 64 KiB | ServerOptions.MaxHeaderBytes |
| JSON bodies | A body with no Content-Type is refused with 415, so a page cannot drive a JSON route cross-site without a CORS preflight | not relaxable |
| Unknown JSON members | Rejected. A client's typo becomes an immediate 422 instead of a silently dropped value. A member naming a path, query, header or cookie field is unknown too | AllowUnknownFields() |
| Duplicate members, invalid UTF-8 | Rejected by encoding/json/v2 | not relaxable |
| Located fields | A path, query, header, cookie, form or file tag the binder cannot reach is a build error, so a request cannot set it from the JSON body | not relaxable |
| Numbers | NaN and infinity are refused in a parameter and by validate.Number | not relaxable |
| Number parameters | JSON's decimal grammar only: hexadecimal, 1_000, .5 and a leading + are refused, so a proxy reading the number by JSON's rules sees the value the handler does | not relaxable |
| URL rules | URL(), HTTPS() and URLWithSchemes() require a hostname, so http://:8080/, which an HTTP client dials as the local machine, is refused | not relaxable |
| Scalar parameters sent twice | A query, header or form parameter that holds one value is refused with 422 when sent more than once, rather than one of its values being chosen | declare a slice to accept a list |
| Cross-origin requests | Denied. No CORS header is emitted until a policy is configured, and a wildcard origin with credentials is refused outright. Every response under a named-origin policy carries Vary: Origin | AppOptions.CORS |
| Cross-site request forgery | Refused once AppOptions.CrossOriginProtection is set: a request a browser sends from another origin with any method but GET, HEAD and OPTIONS is answered 403 before any middleware, guard or handler runs, with no token to manage. See Cross-Origin Requests | TrustedOrigins, InsecureBypassPatterns |
| Sessions | Off until configured. Once on, the cookie is HttpOnly, Secure, SameSite=Lax and named __Host-session, its contents are encrypted and authenticated, and CrossOriginProtection is required beside it, so a cookie a browser attaches on its own cannot be ridden from another site. See Sessions | SessionOptions.SameSite, AllowInsecure, Name |
| Cross-origin WebSocket handshakes | Refused. A handshake is not subject to the same-origin policy and is never preflighted, so CORS cannot cover it. The origin is read strictly, and a handshake with more than one Origin is refused | WSOptions.AllowedOrigins |
| WebSocket and DNS rebinding | The server's own origin is any whose host is the request's Host, until a route lists the names it answers to in WSOptions.AllowedHosts. AppOptions.AllowedHosts goes further: a request for any other host, a handshake included, is refused with 421 before it reaches a route | WSOptions.AllowedHosts, AppOptions.AllowedHosts |
| WebSocket messages | Capped at 1 MiB, refused with 1009 before any of the payload is buffered, read a chunk at a time. The length check cannot overflow | WSOptions.ReadLimit |
| WebSocket message time | One message has 30 seconds to arrive once it has begun, and a bounded number of frames to arrive in. Idle connections are left alone | WSOptions.ReadTimeout |
| WebSocket keepalive | On: a ping every 30 seconds, closed when nothing at all arrived for 10 seconds after a ping, whenever the handler was reading during it | WSOptions.PingInterval, PongTimeout, a negative PingInterval to disable |
| WebSocket connections | 1024 per application and 64 per address, then 503 with a Retry-After. A slot is held until the socket is actually closed. 16 addresses (one IPv6 /52) whose peers answer every ping and send nothing hold every slot unless MaxLifetime is set | WSOptions.MaxConnections, MaxConnectionsPerIP |
| WebSocket and event stream age | Unbounded, until a route sets one | WSOptions.MaxLifetime, SSEOptions.MaxLifetime |
| WebSocket extensions | None negotiated, so no peer can ask the server to hold compression state on its behalf | not relaxable |
| Event stream writes | Given up on after 10 seconds, so a client that stops reading a stream that keeps writing cannot pin a goroutine and a full socket buffer once the buffers between them fill. A stream that sends only keepalives never fills them, and is never ended by this | SSEOptions.WriteTimeout |
| Event streams | 1024 per application and 64 per address, with a keepalive comment every 15 seconds. 16 addresses (one IPv6 /52) that read nothing hold every slot unless MaxLifetime is set | SSEOptions.MaxStreams, MaxStreamsPerIP, KeepAlive |
| Per-address counts | An IPv4 client is counted exactly and an IPv6 client by its /56, for the connection caps and the rate limit alike | ClientIPOptions.ConnectionIPv6Prefix, ConnectionIPv4Prefix |
| Event fields | A name or identifier carrying a line break is refused, because it would end its own field and let what follows be read as events of its own | not relaxable |
| Panics | Logged with a full stack trace, its value cut to 4 KiB, and answered with a generic 500. Nothing derived from the panic reaches the client | not relaxable |
| A failure after the response started | Logged once and the connection aborted, so a truncated export cannot look complete | not relaxable |
| Errors | An error that does not describe itself becomes an opaque 500 with the real cause logged and never transmitted. A StatusCoder renders only its own message, below 500 | AppOptions.ErrorRenderer |
| Error responses | Drop the headers set for the response they replace, and are sent Cache-Control: no-store, so an error is never served as HTML or cached publicly | AppOptions.ErrorRenderer |
| Guarded responses | A route or file mount behind a guard, a request-scoped provider or a verifying security scheme, and any response whose request read its session, is sent Cache-Control: private, no-cache, so a CDN cannot hand one user's response to another (an event stream and a WebSocket keep their own). A verifying scheme adds Vary on the header its credential arrives in, and a session Vary: Cookie. A response carrying the session cookie stays private whatever sets Cache-Control after it | set Cache-Control in the handler, except on a response that carries the session cookie |
| Request identifiers | Not trusted from the client, because an attacker-controlled identifier is an attacker-controlled log field | AppOptions.TrustRequestIDHeader |
| Trace context | Off until a Tracer is named. A span is named after the route template, never the path, which carries identifiers, and traceparent and tracestate are read strictly. A valid traceparent is continued by default, so a service the internet reaches should believe only its proxy's. See Observability | TracingOptions.Parent |
| Forwarding headers | Not believed. X-Forwarded-For is read only for a request that arrived from a proxy named in TrustedProxies, and from the right | AppOptions.ClientIP |
| Host header | Not checked until the application lists the hosts it serves; then a request for any other is answered 421 before anything else runs, and an HTTPS redirect only ever names a listed host. See Allowed Hosts and HTTPS | AppOptions.AllowedHosts, AppOptions.RedirectHTTPS |
| Rate limiting | Off until a policy names a quota. Once one exists, counters are bounded in number and in key length, a full table makes room within the quota being counted (only a quota holding no counter takes one from the largest), a storage that stops answering is cut off after 2 seconds, and a storage that fails refuses traffic | AppOptions.RateLimit, FailOpen, StorageTimeout |
| Secrets in logs | Any attribute key that contains a term such as authorization, token, api-key, password, session, jwt or dsn is redacted, and so is every value inside a matching group and every matching entry of a logged http.Header, url.Values or string map | LoggerOptions.RedactKeys |
| Log records | One line each. Control characters, C1 controls, bidirectional controls and invalid UTF-8 are escaped, so a request path cannot forge a line or drive a terminal | not relaxable |
| Token comparison | Constant-time, over hashed inputs, so neither the contents nor the length of a secret leaks through timing | muzak.SecureCompare |
| Security headers | X-Content-Type-Options, X-Frame-Options and a referrer policy on every response, and Strict-Transport-Security on a response over TLS (not for localhost or an address). No Content-Security-Policy: a page sets its own | AppOptions.DisableSecurityHeaders |
| Documentation UI | Not served at all until AppOptions.DocsUI names one. A page that is configured is embedded in the binary, fetches nothing from a third party, is served under a content security policy that hashes its own inline script and permits no network access beyond this origin, runs the application's guards and providers, and counts against its rate limit | AppOptions.DocsUI, AppOptions.DisableDocs |
| Static files | A dotfile answers 404, a file's type comes from its extension and never its content, HTML and SVG are sent under Content-Security-Policy: sandbox, a symbolic link cannot leave the directory, and a directory with a mount of its own is reachable only through it | AllowDotfiles, AllowActiveContent |
| File mounts | Run the rate limit, the guards and every provider of the router they were registered on | SkipRateLimit(), a router without them |
| Files and byte bodies | Bytes, Stream and FileResponse are sent with X-Content-Type-Options: nosniff. A FileResponse takes its type from the file's extension, never its content, refuses a dotfile, and sends HTML, SVG and XML sandboxed. See Files, Streams and Redirects | FileResponse.AllowActiveContent |
| Redirects | muzak.Redirect accepts only a path on this origin. //, /\, control characters and a path that decodes or resolves to another host are refused, and an absolute URL is sent only to a host the route lists | RedirectHosts(...), Redirect.External |
| Outbound requests | muzak.NewClient refuses loopback, private, link-local and cloud metadata addresses at the moment it connects, so a name that resolves to one, DNS rebinding and a redirect all meet the same check. Every call is bounded (30 seconds, a 10 MiB body, five redirects, never from https to http), and only a request that is safe to send twice is retried. See HTTP Client | ClientOptions.AllowPrivateNetworks, AllowedNetworks, DeniedNetworks |
| Configuration | A required setting refuses an empty value, and a value read by a secret field is hidden from every other field that reads the same variable | drop required, or give it a default |
| Validation | Automatic for any model that declares rules. There is no option to remember, so a model cannot be left unvalidated by forgetting one. A report holds at most 100 failures and follows nested models 32 levels deep, and Unique() is linear in the size of the collection | SkipValidation() |
| Versioning | Off until a type is named. Once on, a route that declares no version answers nothing rather than being served unversioned, so a resource is opted into being version-independent deliberately. A header sent on several lines names no version | AppOptions.Versioning, WithVersion, VersionNeutral |
| Late configuration | Use, Include, route and mount registration after the application was built panic, so a guard added late cannot be dropped silently | not relaxable |
The one deliberate exception
Rate limiting is off until a quota is declared. There is no limit that is right for every application, and a default one would be a number nobody chose refusing traffic nobody expected.
What is safe by default is what happens once a policy exists: the counters are bounded, the address is not taken from a header anyone can write, an IPv6 client cannot multiply its budget by rotating addresses inside its own range, and a storage that stops answering stops traffic rather than stopping the limit. See Rate Limiting.
One more thing is unbounded until a route says otherwise, and it is worth saying: how long a
WebSocket or an event stream may stay open. Keepalive closes a peer that stops answering, and the
write timeout bounds one write, but a peer that answers every ping and says nothing, or a client
that reads nothing while comments trickle in, costs a connection, a goroutine and a file
descriptor until the server stops, and nothing but a ceiling on age can tell it from one that is
listening. MaxLifetime puts that decision in your hands, and it is left unset because there is
no age that is right for every stream.
The defaults as constants
Every one of them is exported, so a deployment can read them rather than guess.
muzak.DefaultMaxBodySize // 1 << 20
muzak.DefaultMaxUploadSize // 32 << 20
muzak.DefaultMaxHeaderBytes // 64 << 10
muzak.DefaultReadHeaderTimeout // 5s
muzak.DefaultReadTimeout // 30s
muzak.DefaultWriteTimeout // 30s
muzak.DefaultIdleTimeout // 120s
muzak.DefaultShutdownTimeout // 15s
muzak.DefaultWSReadLimit // 1 << 20
muzak.DefaultWSReadTimeout // 30s
muzak.DefaultWSWriteTimeout // 10s
muzak.DefaultWSMaxConnections // 1024
muzak.DefaultWSMaxConnectionsPerIP // 64
muzak.DefaultWSCloseGracePeriod // 250ms
muzak.DefaultWSPingInterval // 30s
muzak.DefaultWSPongTimeout // 10s
muzak.DefaultSSEKeepAlive // 15s
muzak.DefaultSSEWriteTimeout // 10s
muzak.DefaultSSEMaxStreams // 1024
muzak.DefaultSSEMaxStreamsPerIP // 64
muzak.DefaultSSEHandshakeTimeout // 30s, for SSEDial
muzak.DefaultConnectionIPv6Prefix // 56
muzak.DefaultConnectionIPv4Prefix // 32
muzak.DefaultRateLimitMaxEntries // 100000
muzak.DefaultRateLimitSweepInterval // 1m
muzak.DefaultRateLimitStorageTimeout // 2s
muzak.MaxValidationDetails // 100
muzak.MaxNestedDepth // 32
muzak.DefaultCompressionMinSize // 1400
muzak.DefaultForwardedHeader // "X-Forwarded-For"
Things the type system does rather than a default
Some of what would be a runtime setting elsewhere is not a setting here at all.
A response cannot leak a field it does not declare. The handler's return type is the
response model. There is no filtering pass to configure and no response_model to forget,
because a field that is not on Out has no way to be written.
// Adding a column to this changes nothing a client can see.
type Item struct {
ID string
Name string
InternalNote string
}
// Only these three members exist on the wire.
type ItemOut struct {
ID string `json:"id"`
Name string `json:"name"`
}
A crafted body cannot reach a located field. When an input mixes a path parameter with
body members, the body is decoded into a struct of the body members alone, so a member naming a
located field is an unknown member, refused with 422, rather than a value that reaches it or
is silently dropped.
Two concurrent requests cannot see each other's dependencies. Resolved values live on
the request's Context and are cleared when it returns to the pool. There is a test for
exactly that, run under the race detector.
A misconfiguration is a build error, not a surprise. Duplicate routes, unbindable input
types, path parameters no field binds or declared twice, a static route that could never match,
duplicate operation identifiers, malformed prefixes, a wildcard CORS origin combined with
credentials, a CORS or WebSocket origin that could never match or is "null", a WebSocket
AllowedHosts entry no Host header can equal, a MaxConnections set on a
route, a missing static directory, two mounts at one path, a location tag the binder cannot
reach, a body type the JSON decoder refuses, a Nested that would validate nothing, a
certificate without its key, a security scheme, category or title that cannot be described,
alternatives that mix a verifying security scheme with a descriptive one, a Dep field no
provider fills, sessions without cross-origin protection or with a secret under 32 bytes, an
HTTPS redirect with no host it may name, an AllowedHosts entry that can never match, a
Timeout on an event stream or WebSocket route, a health path a route already answers, and an
OpenAPI document that cannot be encoded as JSON: all reported together when the application is
built, before a socket is opened.
A deployment checklist
Things worth deciding explicitly, because the safe default may not be the correct one for your deployment:
ClientIP.TrustedProxiesif anything sits in front of the service, or every request is attributed to the proxy. See Behind a Proxy.CORSif a browser on another origin calls the API. See CORS.CrossOriginProtectionif a browser sends the service cookies, which sessions require anyway. See Cross-Origin Requests and Sessions.AppOptions.AllowedHosts, so a request with a forgedHostheader, a WebSocket handshake included, is refused with421, andRedirectHTTPSif plain HTTP reaches the service. See Allowed Hosts and HTTPS.WSOptions.AllowedOriginsif a browser opens WebSocket connections, and a host list if the server is on a loopback or private address:AppOptions.AllowedHostsfor every request, orWSOptions.AllowedHostsfor one route's handshakes alone.MaxLifetime, in minutes rather than hours, on every WebSocket and event stream route a client you do not control can open. Without it, 16 addresses can hold every slot of either.SSEOptions.MaxStreamsagainstMaxStreamsPerIP, since the one divided by the other is how many addresses fill every stream slot, andClientIPOptions.ConnectionIPv6Prefix: 48so that an IPv6 client counts by its site's allocation, for streams and WebSocket connections alike. See Server-Sent Events.- A rate limit policy, and a shared storage if the service runs more than once.
Secure: trueon every cookie once the service is served over TLS. See Cookies.DisableDocsif the deployment must not describe itself, or a guard declared where the routers are included, rather than onNew, if only the API is private.ServerOptions.UnencryptedHTTP2on a platform that speaksh2cto the container, and aTrustedProxiesrange for it. See Running on Cloud Run.Cache-Controlin a handler that serves public data from behind a guard.Required()on every JSON body member a client must send, since the decoder accepts a body that leaves any member out and the generated document follows the rules you declare.- A
Serverslist in the OpenAPI options, so a generated client points at the right host.
Where to go next
Authentication covers proving who a caller is, and Behind a Proxy covers the one default that is wrong in the other direction behind a load balancer.