Report vulnerabilities privately through GitHub's private vulnerability reporting: the repository's Security tab, Report a vulnerability (a draft security advisory only the maintainers see). Please do not open a public issue. Include the version (tag or commit), what an attacker controls, and a reproduction if you have one. We aim to acknowledge a report within a week, and to publish a fix and an advisory (with a CVE where one applies) once a fixed release is tagged.
Supported versions: the latest minor release (v0.x: see the README's compatibility policy). Fixes are not backported to older minors.
link carries sessions over one byte stream the caller dials and secures. This section says what the library guarantees against each kind of peer, and what is left to the caller. "Peer" is whoever is at the other end of the stream; WIRE.md defines the bytes.
- No encryption or integrity. link puts frames on the caller's stream
as they are. Use TLS (or another secure channel) for anything that
crosses a network you do not control; the TLS state is passed to the
Authenticatorfor channel binding. - No authentication of its own. The
Authenticatordecides who a dialer is.NoAuthaccepts any dialer that sends no credential: use it only where the stream is already authenticated (mutual TLS, a pipe). - No authorization. A bound peer may open sessions (up to the limits
below) and send anything on them. What a session may do, given the
Identityand the header, is the caller's decision.
A listener reads at most 5 + 24 + 4096 bytes from a dialer before it calls
Authenticator.Verify (magic and version, the fixed hello, at most
MaxAuth of credential), allocates at most MaxAuth bytes for it, and
validates every field (window, max sessions, liveness) against fixed
bounds; anything else is refused before a reply. The fuzz targets
FuzzServerHello and FuzzClientReply check these bounds on arbitrary
bytes. A refused dialer reads one status byte, never the reason.
The caller must:
- Bound the handshake.
Serverwaits as long as itsctxallows: a dialer that sends its hello one byte at a time (slowloris), or not at all, holds the connection and a goroutine until then. Pass actxwith a deadline (the example uses 10 s;peerlink.Nodeuses its dead-peer bound), and limit concurrent handshakes per source as for any listener. - Write a safe
Verify. It runs on attacker-controlled bytes: compare secrets in constant time (crypto/subtle.ConstantTimeCompare), bound its own work byctx, and never trustmethodbeyond choosing how to checkpayload. Its error is not sent to the dialer. - Name peers uniquely. The returned
Identity.Subjectis how callers tell peers apart (peerlink.Nodenames an accepted peer by it, and opens reverse-direction sessions to the connections of that name). Two peers with the same Subject (or an empty one, as withNoAuth) are the same peer to such a caller; give each a distinct Subject, or name them withpeerlink.Config.ServedPeer.
A bound peer can only affect its own connection. Every frame it sends is
validated (ErrFrame, ErrCredit, ErrFull end the connection) and
FuzzRecv, FuzzRecvV2 and FuzzMuxServeV2 check the receive path on
arbitrary bytes. Resource use per connection is bounded:
| resource | bound | set by |
|---|---|---|
| buffered session data | max_sessions x window per direction | this side's Config.MaxSessions and Config.Window (defaults 1024 x 1 MiB) |
| sessions the peer opens | Config.MaxSessions (an open past it ends the connection) |
this side |
| an open's header | 64 KiB per open session | the wire |
| one frame in flight | 1 MiB + 256 bytes (read, then delivered or dropped) | the wire |
| credit | a peer that sends past a window, or grants past its own, is ErrCredit |
the wire |
| answers to pings | one pong buffered at a time: a ping flood from a peer that does not read is answered once per write, never buffered without bound (TestPingFloodBoundsBuffer) |
the library |
| refused opens | answered with a reset that waits for room in the write buffer, so a peer that opens without reading stalls its own connection, which liveness then ends | the library |
Choose MaxSessions and Window so that their product, times the
connections you accept, fits your memory: the defaults allow 1 GiB of
buffered data per connection from a peer that sends without reading.
Liveness: a listener takes the dialer's interval and timeout unless it
sets Config.Liveness. The interval is bounded (1 s to 15 s); the timeout
is not, so a listener that must bound how long a vanished dialer holds a
connection sets its own Liveness.
Things a peer can do to itself, by design: send GOAWAY (this side then
opens nothing more on that connection), reset or close its sessions, send
pongs that were never asked for (Observer.PingRTT reports what the peer
echoes, so treat RTTs from untrusted peers as hints), or answer a GOAWAY's
ping early (its own sessions then end sooner).
A dialer reads at most 14 bytes of reply before binding, with every field
validated. A Pool redials Min connections with its Backoff, also
when a listener takes connections and refuses them (GOAWAY, or a close
within the liveness interval, before any session): a listener that is
shutting down does not cause a reconnect storm
(TestPoolBacksOffRefusingPeer).
Sessions joined end to end (Join, Forward; WIRE.md section 12) pass
through hops, each of which is a transport, not a party the ends can
trust:
- Authentication stays per link. Each connection keeps its own
Authenticatorand TLS; a hop knows its neighbours, never the origin unless the header says so (sign it in the application if that matters). The 16-byte trace id is for correlating logs: not a secret, not an authenticator. - A hop sees and can change everything it carries: the header, the trace and hop count, sizes and timing, the bytes (unless the ends encrypt), and the answers. It can refuse, delay, reset with any code, forge an accept or an abort, rewrite the header or the bytes. A forged refusal or abort is a denial of service; a forged accept is caught by end-to-end TLS inside the session (a pinned certificate), which is the way to make a path confidential and authentic. link does no end-to-end encryption.
- Bounded per hop. A chain-open's path data is 17 fixed bytes (a
counter, not a list); an abort's message at most 1024 bytes of UTF-8
(
FuzzRecvV2,FuzzAnswercheck the decoders); a forwarded session holds at most its two windows and two 256 KiB write batches at a hop, and each side counts against its own connection'sMaxSessions(TestChainStalledFarReader). A routing loop costs at mostForwardConfig.MaxHopsjoins (default 8,TestChainMaxHops); an open that is never answered holds its hop untilAnswerTimeout(set one where the next leg is not trusted to answer) or until the origin gives up.FuzzForwarddrives a Forward hop with arbitrary frames. - A peer's answers are checked: a second accept, an accept from the
opener, a refusal after an accept, or data before the accept ends the
connection (
ErrFrame).
peerlink.Node inherits link's guarantees and bounds each accepted
connection's handshake by its dead-peer bound. quiclink uses QUIC's
TLS 1.3 (0-RTT off: its data can be replayed); a stream's header preamble
is read under the dead-peer bound and is at most 64 KiB
(FuzzPreamble); an ALPNChain answer is at most 1032 bytes and its
message valid UTF-8 (FuzzAnswer). grpclink leaves transport security and peer naming to
gRPC's credentials and Config.Identify.
2026-10-16, v0.10.0: the chaining threat model above (hops as transports, answers forgeable by a hop, bounds on path data, abort messages, in-flight bytes per hop and routing loops), with fuzz targets for the new decoders and the Forward hop.
2026-10-14, v0.7.2: review of hello parsing, the Authenticator
contract and resource exhaustion. Fixed: a ping flood from a peer that
does not read grew the write buffer without bound (pongs now coalesce);
a Pool redialled a refusing listener in a tight loop (now with
Backoff). Documented: the handshake deadline, the Verify contract, the
liveness timeout's missing upper bound, and the memory budget.