5.0.0: remove Uri and the timeouts from the public API, add Shadowsocks - #3
Merged
Merged
Conversation
…ract Found by pointing a checker at the public API and running it over thousands of real nodes — neither hole is visible from the happy path, and neither is visible to a caller that catches Exception. The exception contract. ConnectAsync is supposed to throw ProxyProtocolException for anything that goes wrong on the wire, NotSupportedException for a link describing something we cannot speak, and the ArgumentException family for the caller's own mistake. Two paths broke it. CreateSocket() sat outside the guarded region in both overloads, so a failed bind or handle exhaustion under a few thousand concurrent checks escaped as a raw SocketException. And AuthenticationException derives from SystemException, not IOException, so it slipped past the `ex is IOException or SocketException` guard entirely — meaning an expired certificate or an SNI the server will not serve, the most common way a TLS-carried node dies, was never reported as a proxy error at all. Internal/TlsHandshake now owns every client-side handshake so there is one place for that translation, under the new ProxyErrorCode.TlsHandshakeFailed. The code is appended, so existing values keep their numbers. Bulk parsing. Proxy.TryCreate returns the reason a link was rejected and never throws, whatever the input: a subscription is other people's text, one bad line in a thousand must not end a run, and a rejection naming an exception type the parsers do not raise on purpose is how a parser bug makes itself visible instead of hiding behind a caller's catch. IProxyClient.SourceLink carries the text a client was built from. ProxyUri cannot stand in: for vless, trojan and vmess it is only scheme://host:port, so a node written out that way has lost its uuid, sni and transport and cannot be reached again. Added as a default interface member, so nothing implementing the interface breaks. ProxyClientFactory is gone; its methods are static on Proxy, which was already the static entry point and — unlike ProxyClient — is not also the base class implementations derive from. That matters concretely: Create must return IProxyClient rather than ProxyClient, because a future QUIC client cannot derive from it, and a Create on a type named ProxyClient returning something else reads wrong. The singleton Instance goes with it; the class had no state. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ShadowsocksClient joins the share-link family next to VLESS, Trojan and VMess. Four AEAD ciphers: aes-128-gcm, aes-192-gcm, aes-256-gcm and chacha20-ietf-poly1305. Salt length equals key length, so aes-192-gcm carries a 24-byte salt. Master key is EVP_BytesToKey(MD5, no salt) over the password; each direction derives its own subkey with HKDF-SHA1 and info "ss-subkey". The nonce is a 12-byte little-endian counter that advances after every AEAD operation, twice per chunk. A chunk carries the plaintext length in two sealed bytes, then the sealed payload; a decrypted length above 0x3FFF is rejected, never masked. The server sends its salt only after the target replies, so the reader takes it lazily on the first Read. ConnectAsync writes salt and address header eagerly and returns. A FIN at a chunk boundary is EOF; anywhere else it is an error, and so is a failed tag. ShadowsocksShareLink reads both grammars in the wild: the legacy blob where the whole authority is base64, and SIP002 with base64, base64url or plain method:password userinfo. Padding may be absent or arrive as %3D. Port defaults to 8388. StripHtmlAmpPrefix keeps plugin= from hiding behind &. Everything else fails by name before a byte is written: the 2022-blake3 family, legacy stream ciphers, none and plain, xchacha20-ietf-poly1305 and the CCM/GCM-SIV/SM4 variants the BCL cannot do, and every plugin. chacha20-ietf-poly1305 also checks ChaCha20Poly1305.IsSupported, which is false on every shipped Windows 10. Wire bytes are pinned to vectors from an independent Python generator validated against RFC 5869, RFC 8439, the NIST GCM cases and the OpenSSL binary. The two-chunk vector is what catches a wrong nonce increment. Docker cases tunnel through Xray and sing-box on both. Write seals consecutive chunks into one send; Read fills a window with one inner read and opens straight into the caller's buffer when it fits. DeriveSubkey builds HKDF from HMACSHA1 one-shots, the client salt lives in an InlineArray, and the share-link parser slices spans instead of allocating substrings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RhwLwckpNR8tqpgrbXK9Fv
A SIP002 userinfo made of base64 characters was decoded even when it was just the password: the bytes then hold a ':' by chance and everything before it became the method. 1057 of 8069 real ss:// links from a public list parsed that way. The client refused them at connect time, so the bytes were never wrong — but TryParse said yes, and a caller filtering a subscription kept them. The base64 branch now requires the decoded method to be short ASCII: letters, digits, '-', '_', '.', '+'. Text the producer wrote itself is untouched, so an unknown but well-formed name still parses and the client names it when it refuses. Parse rate over that list drops from 99.0% to the honest 86.9%. CorpusCheck learns ss://: its own row in the parse report, its own pool in --live, and a redactor that prints nothing at all for the legacy grammar, where method, password, host and port share one base64 blob. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HttpHelper wrote the target host as given, so ::1 went out as "CONNECT ::1:443" and "Host: ::1:443". Neither parses: RFC 9112 takes the authority from RFC 3986, where an IPv6 literal is bracketed, and without the brackets the address's colons run into the port. SOCKS accepted both spellings, since IPAddress.TryParse takes brackets, so the same call worked or failed depending on the proxy family. A host that already carries brackets is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
Both socket paths, ProxyClient.CreateSocket and the Uri fast path in Proxy, created an IPv4-only socket. Connecting it to an IPv6 address throws NotSupportedException, which the connect guard turned into ConnectionFailed: a healthy node at an IPv6 address was reported dead, and a mass checker cannot tell that apart from a real failure. The socket is now dual-mode, Socket(SocketType, ProtocolType), which also lets a proxy name that resolves to both families try both, in the order the OS resolver returns them. A LocalEndPoint still decides the family, since binding is the caller choosing an interface. CreateSocket now disposes the socket when setup or the bind fails, instead of leaving the handle to the finalizer. The IPv6 tests need a machine that can listen on ::1 and report as skipped elsewhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
HttpsProxyClient set TargetHost to the CONNECT target, so SNI named the site being tunnelled to and the proxy's certificate was checked against that name. Under the default validation every HTTPS proxy failed unless its certificate happened to name the target. The TLS session is with the proxy, so it now names the proxy, as the Uri path in ProxyConnector already did. The bug dates from the first commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
IProxyClient.ConnectAsync and Proxy.ConnectAsync(link, ...) accept a DnsEndPoint or an IPEndPoint next to host and port. It is the shape Socket.ConnectAsync has and the one SocketsHttpHandler.ConnectCallback hands over, so an HttpClient goes through any proxy this library speaks with a one-line callback. On IProxyClient they are default interface members forwarding to the host-and-port overloads, so an implementation that does not derive from ProxyClient gets them without a change; ProxyClient implements them publicly so they are reachable on the class too. An IPv4-mapped IPv6 address, which is how a dual-mode socket reports an IPv4 peer, is sent as IPv4 rather than under an IPv6 address type. Any other EndPoint is an ArgumentException, which keeps ConnectAsync's exception contract. No EndPoint overloads were added next to the Uri ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
EncodeString wrote into the rented request buffer and cast the length with checked((byte)). ArrayPool rounds the 513-byte request up to 1024, so a username, password or host of 256 to about 1000 UTF-8 bytes fit the buffer, overflowed the cast, and left ConnectAsync as a raw OverflowException instead of ProxyErrorCode.SocksStringTooLong. The write is now capped at 255 bytes with Encoding.UTF8.TryGetBytes, as ProxyAddress already does. The request buffer, which held the SOCKS5 username and password or the SOCKS4 user id, now goes back to the pool cleared when there were credentials. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
The scratch buffer holding user:password and the CONNECT request holding its base64 both went back to ArrayPool.Shared as they were, against the repo's own rule for credential buffers. Not unit-tested: whether the next Rent hands back the same array is a pool implementation detail, so such a test could pass without the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
RealityTlsStream and WebSocketStream did not override Read(Span<byte>) and Write(ReadOnlySpan<byte>), so Stream's fallback ran: it rents an array, reads or writes through it and returns it to the shared pool uncleared, leaving decrypted application data, or under WebSocket the tunnel bytes including a VLESS id, for the next renter to read. RealityTlsStream now copies a record already in hand straight into the span and only goes through the async path to wait for the next record; its write copies into a rented array and clears it. WebSocketStream keeps the rented array but clears it. ReadByte and WriteByte on RealityTlsStream no longer allocate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
The grammar check looked for '@' anywhere in the payload, including the '#remark' producers append after the base64, so "#@channel" or "#Node @ Telegram" sent the link to the URI parser and it failed as "not a well-formed URI". The '@' now only counts before the fragment. Also, "ps":"" no longer hides a remark given in the fragment. An empty ps with no fragment still gives an empty remark. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
ParseCredentials split Uri.UserInfo as it was, still percent-encoded, so socks5://user:p%40ss@host sent "p%40ss" to the proxy. A password with ':', '@' or '/' can only be written into a link escaped, which made exactly those unusable. Each half is now unescaped after the split. The client constructor composed ProxyUri from the raw credentials, so a password with '@', '#', '/' or '?' made it throw UriFormatException, including from Proxy.Create(type, host, port, credentials). They are escaped now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
BREAKING CHANGE, for 5.0.0. Removed: - IProxyClient.ProxyUri and ProxyClient.ProxyUri - Proxy.ConnectAsync(Uri, string, int, ...) with and without a timeout - Proxy.ConnectAsync(Uri, Stream, string, int, ...) - ProxyUriExtensions, with both ConnectThroughProxyAsync overloads A Uri cannot hold most vmess:// links or legacy ss:// ones. For vless, trojan, vmess and ss it kept only scheme://host:port, dropping the uuid, sni and transport. It could not hold a password with '@' at all, and anything that logged ProxyUri logged the password in clear. Instead: - Proxy.ConnectAsync(string link, ...) and Proxy.Create(string) take every scheme. - Proxy.Create(Uri) stays, as an adapter for WebProxy.Address and IWebProxy.GetProxy. - ProxyClient.ToString() is scheme://host:port, without credentials, for logs. - ProxyHost is unbracketed for IPv6 however the host arrived; a Uri authority used to give "[::1]" where a share link gave "::1". Removing the Uri fast path also removes three defects that lived only there: its https branch authenticated TLS directly instead of through TlsHandshake, so a certificate failure escaped as AuthenticationException; it rethrew raw IOException and SocketException from the handshake; and it carried a third copy of the connect-timeout logic. ProxyConnector now dispatches on ProxyType rather than scheme strings, and HttpHelper no longer takes the Uri it never used. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
ProxyClient had the connect-and-handshake logic twice, and the timeout overload ran it under a timer that disposed the socket and set a flag. Three defects followed from that shape: - the timer was disposed only after the handshake returned, so a timeout firing in that window disposed the socket of a stream already handed back, and no exception was raised; - new NetworkStream sat outside the guard, so a socket disposed underneath it threw a raw IOException; - an invalid timeout threw from CreateTimer after the socket existed, and nothing disposed it. Both overloads now share ConnectCoreAsync. The timeout is a CancellationTokenSource linked to the caller's token, passed to the connect and to every await of the handshake, and nothing it registers outlives the call. An invalid timeout is rejected before any socket exists. The caller's own cancellation is now OperationCanceledException carrying the caller's token, in every phase; the TCP connect used to report it as ConnectionFailed while the handshake threw it bare. A timeout stays ProxyErrorCode.Timeout. AGENTS.md item 18 records the rule. ConnectCancellationTest pins the contract. Only its invalid-timeout cases fail on the old code: the race, and a cancellation landing in the TCP connect itself, cannot be reproduced deterministically. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
Every TLS 1.3 key-schedule output fits in the first HKDF-Expand block, T(1) = HMAC(secret, HkdfLabel || 0x01), and the REALITY auth key is Extract followed by that same one block. Both now call HMACSHA256/384.HashData directly instead of HKDF.Expand and HKDF.DeriveKey, which allocate on every call on net9 and net10; a handshake makes sixteen Expand calls and one DeriveKey. ExpandLabel refuses an output longer than a hash and a hash other than SHA-256 or SHA-384, and zeroes its scratch block. The PRK is zeroed too. Pinned by TlsKeyScheduleTest (RFC 8448's trace) and RealityAuthTest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
A plain async ValueTask override boxes its state machine whenever it completes asynchronously, which for a tunnel stream is most reads, for as long as the connection lives, once per layer. PrefixedStream, VlessResponseStream and VmessResponseStream are pass-throughs once their prefix or header is consumed, so their ReadAsync is no longer async: it returns the inner ValueTask directly and keeps an async method only for the header read. VmessStream, WebSocketStream and VisionStream do real work per call, so their async methods use PoolingAsyncValueTaskMethodBuilder, as ShadowsocksStream and RealityTlsStream already did. VisionStream's fill loops are now Stream.ReadAtLeast/ReadAtLeastAsync, and their throwOnEof parameter, which every caller passed as false, is gone. One visible difference: a read on a disposed pass-through stream now throws ObjectDisposedException synchronously instead of from the returned ValueTask. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
… headers
The hand-written case-insensitive compare and SP/HTAB trim are Ascii.EqualsIgnoreCase and
MemoryExtensions.Trim(" \t"u8). Not Ascii.Trim, which would also strip \v, \f and \r.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
HttpsProxyClient installed a default RemoteCertificateValidationCallback that accepted exactly what SslStream accepts without one: no policy errors. The only thing it changed was the failure. TlsHandshake carries the handshake's message into TlsHandshakeFailed, and a callback's refusal reads "The remote certificate was rejected by the provided RemoteCertificateValidationCallback" where SslStream's own names the chain error. The callback is now null unless the caller set one. The ALPN list is one shared instance instead of a new list per connection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
IsAotCompatible marks the package trimmable and makes a reflection-based dependency, a JsonSerializer call for instance, fail the build rather than a user's published app. The library builds with no IL warnings on all four targets: the vmess JSON is read with JsonDocument, which does not reflect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
…as wrong The host and port checks in ProxyClient use ArgumentException.ThrowIfNullOrEmpty and the ArgumentOutOfRangeException helpers. The types and parameter names are unchanged; the messages now include the rejected value, and the host message no longer says "between 0 and 256 characters" for a limit of 255. IProxyClient described the TimeSpan timeout as "in milliseconds". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
In a base64 group ending "==" the last data character carries four bits no byte uses, and two before a single "=". Go's decoder, which Xray and most link producers run, ignores them, and so does .NET 10's Convert. .NET 11's rejects the group. The same vmess or ss link therefore parsed on one of the test project's targets and not on the other. ShareLinkBase64.TryNormalize is now the one place share-link base64 is prepared: the url-safe alphabet mapped to the standard one, whitespace dropped, padding completed, and those trailing bits cleared, which decodes to the bytes both older decoders produced. It replaces the two copies of that loop in VmessShareLink and ShadowsocksShareLink. The vmess relaxed decoder now clears its pooled char buffer, which held the base64 of a JSON carrying the user id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
…ecting VlessClient decoded pbk and parsed sid inside ConnectAsync, so a bad value left that call as a FormatException, which is not one of the exceptions it may throw. Both are now checked where the configuration enters: VlessShareLink.TryParse refuses the link with the value named, and the VlessClient constructor throws ArgumentException for hand-built options. The decoded key is kept on the client instead of being decoded on every connect. A REALITY configuration with no key at all is still NotSupportedException at connect, as before. The key goes through ShareLinkBase64, so one whose last character has unused bits set decodes on .NET 11 as it does on .NET 10. RealityAuth gains TryDecodePublicKey and TryParseShortId; ParseShortId throws from the latter. Test changes: Client_Reality_MalformedPublicKey_ThrowsFormatBeforeWriting pinned the old behaviour and is replaced by tests that the link and the constructor refuse the value. Two parsing fixtures used pbk=PUBKEY, which is not a key and is now refused; they use a real one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
BREAKING CHANGE, for 5.0.0. IProxyClient.ReadTimeout and WriteTimeout are removed with their ProxyClient implementations, and CreateSocket no longer copies them to Socket.ReceiveTimeout and SendTimeout. They were documented as the timeout for sending and receiving data through the proxy, and never were. Socket.ReceiveTimeout and SendTimeout bound only synchronous calls: in a loopback probe an async read with ReceiveTimeout=300 was still pending after 1.5 s, while a synchronous read timed out at 308 ms. Every handshake in the library is asynchronous, so neither property ever applied inside ConnectAsync. The one thing they did do is lost. A caller's own synchronous Read or Write on the returned stream honoured them when that stream was the raw NetworkStream, which is the HTTP and SOCKS tunnels with no overread. Migration: set Stream.ReadTimeout / WriteTimeout on the returned stream when its CanTimeout is true, or pass a CancellationToken to async reads and writes. ConnectAsync(..., TimeSpan timeout, ...) already bounds the connect and the handshake. The RecordingClient test double drops the two members. CorpusCheck's live prober drops two assignments that never took effect, since all its I/O is async. The README loses them from the Factory API example and the options table and says how to bound reads instead, and AGENTS.md records the removal next to ProxyUri's. No test asserted on socket timeouts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
Two places where the managed REALITY client accepted more than Go's crypto/tls client
does. Neither was exploitable: the bytes only reached a caller once the peer had also
passed the REALITY certificate HMAC check, so they came from the server itself. But no
real server needs either leniency, and REALITY's server side is Go's crypto/tls.
Application data before the server's Finished. A record of application data under
the server's handshake keys was kept in a list and handed to the caller as the first
bytes of the tunnel. RFC 8446 §2 says application data MUST NOT be sent before the
Finished, and Go's client refuses it in readRecordOrCCS (src/crypto/tls/conn.go):
case recordTypeApplicationData:
if !handshakeComplete || expectChangeCipherSpec {
return c.in.setErrorLocked(c.sendAlert(alertUnexpectedMessage))
}
It is now a RealityHandshakeException, and the plumbing that existed only for it is
gone: the Leftover list and its 64 KiB cap, the cap of 64 empty application-data
records during the handshake (Go refuses the first, empty or not), and
RealityTlsStream's third constructor parameter with the copy it fed. The handshake loop
stays bounded without them; ChangeCipherSpec count, flight length and message size keep
their own limits.
Handshake bytes after the server's Finished. Once the Finished is read, reads switch to
the application keys, and handshake bytes left over from the Finished's record were
dropped without a word. RFC 8446 §5.1: handshake messages MUST NOT span key changes,
and an implementation that detects one MUST terminate with unexpected_message. The
client already checked this after the ServerHello. It now checks after the Finished
too, where Go does: certificate and Finished verified, its own Finished not yet sent.
Go's check sits in conn.go and is called from readServerFinished in
handshake_client_tls13.go:
func (c *Conn) setReadTrafficSecret(...) error {
// Ensure that there are no buffered handshake messages before changing the
// read keys, since that can cause messages to be parsed that were encrypted
// using old keys which are no longer appropriate.
if c.handLen() != 0 {
...
return errors.New("tls: handshake buffer not empty before setting read traffic secret")
}
Tests: HostilePeerTest gains a peer that holds real keys: an X25519 exchange, the RFC
8446 key schedule, a certificate bound to the REALITY auth key, and a Finished over the
real transcript. It is the only way to reach a check made after the ServerHello. Its
unbent flight, with application data under the application keys in the same burst,
completes and delivers that data. That control is what shows the two refusals are
caused by the bent part: application data under the handshake keys before the Finished,
and a NewSessionTicket sharing the Finished's record. Against the previous client both
refusal tests fail, because it completed the handshake.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
SocksHelper rents one buffer for every message it writes, sized by BufferSize = 513, with a comment naming SOCKS5 username/password authentication as the largest message. It is not. A SOCKS4a request is VN, CD, DSTPORT and DSTIP, a user id of up to 255 bytes and its NUL, and a host of up to 255 bytes and its NUL: 520 bytes. It worked only because ArrayPool.Shared.Rent(513) returns a 1024-byte array. A buffer of exactly 513 would have refused a host of 250 to 255 bytes after a 255-byte user id as too long, and thrown IndexOutOfRangeException for a 249-byte one when writing its NUL. The constant is now 520. Its comment lists the largest size of every message the helper builds, and of the replies it reads into the same buffer. Nothing changes at run time, because the pool hands out the same array. Test: BufferSize_HoldsTheLargestMessageTheHelperWrites sends the longest SOCKS4a request and the longest SOCKS5 authentication through a stream that records its largest write. It checks the SOCKS4a bytes and requires BufferSize to equal the larger of the two writes. BufferSize is internal so the test can read it. Against the old constant, made internal but still 513, the test fails on both TFMs with expected 513, actual 520. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
…4 user id
HTTP CONNECT writes the target host into the request line and the Host header as it is, and
ValidateArguments checked only null, empty and length. Reproduced against a loopback proxy:
Proxy.Create("http://127.0.0.1:P").ConnectAsync("example.com HTTP/1.1\r\nX-Injected: yes\r\n\r\n
GET /admin HTTP/1.1\r\nHost: internal.local\r\nX-Pad: x", 443) returned a stream, and the proxy
received an injected header and a second request. SOCKS4 and SOCKS4a strings are NUL-terminated.
A host "good.example\0GET /admin HTTP/1.1\r\n\r\n" over socks4a put everything after the NUL on
the wire as tunnel data. A Socks4aClient with the user id "alice\0evil.example" asking for
good.example made a SOCKS4a server read user alice and host evil.example.
A target host containing a space or an ASCII control character (0x00-0x1F, 0x7F) is the
caller's mistake, so ProxyClient.ValidateArguments throws ArgumentException for it. The message
names the code point and index and does not repeat the host. Non-ASCII names are still allowed.
The check lives there, not in HttpHelper and SocksHelper, because it is about the argument, not
one wire format. No host name contains these characters, and a NUL cuts a name short wherever
it reaches a resolver: on this machine Dns.GetHostAddresses("localhost\0evil.example") returned
localhost's addresses.
ValidateArguments did not run on every path. ConnectAsync(host, port) and the EndPoint overloads
reached it, but ConnectAsync(Stream, host, port), which callers use directly, skipped it on all
nine clients. Each override now runs it before any byte is written or any TLS handshake starts.
That brings the rest of the check to those overloads too: a null or empty host, a host over 255
characters, and a port outside 1-65535 are now ArgumentException there, as they already were
elsewhere. Before, a null host reached the protocol code (a NullReferenceException in
HttpHelper), and a port of 65616 would have gone out as 80 in a SOCKS5 request.
A SOCKS4 user id containing a NUL is refused where the credential enters: the Socks4Client and
Socks4aClient constructors that take a NetworkCredential. Proxy.Create also reaches them for
socks4:// and socks4a:// links with a user. NetworkCredential is mutable, so SocksHelper checks
again before writing the request. The message never contains the user id.
The new tests fail against the old library (stashed, with the new tests kept): 20 of 243 in the
affected classes on each TFM.
- HttpHelperTest: the reproduction through every overload against LoopbackConnectProxy, and
NUL, TAB, LF, CR, 0x1F, space and DEL in a host through the stream overload.
- ProxyFactoryTest: a host with CR LF, refused by all nine link families through the
DnsEndPoint and stream overloads, with nothing written.
- Socks5HelperTest: a NUL in a SOCKS4a host; a NUL in a user id through both constructors and
both link schemes, with no message naming it; and a user id changed after construction.
Client_InternationalisedTargetHost_IsStillSentAsUtf8 guards against refusing too much, and
passes on both the old library and the new.
ShadowsocksShareLinkTest.Client_ConnectAsync_HostNameTooLong_FailsBeforeWriting used 300 ASCII
characters, which the stream overload now refuses with ArgumentException before the address
encoder sees them. It now uses 200 Cyrillic letters (400 bytes), so it still tests the
encoder's own limit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
…tion enters
The REALITY ClientHello is written as one TLS record, and nothing bounded what went into it.
Reproduced: new VlessClient(new VlessOptions { Security = Reality, RealityPublicKey = <valid>,
Sni = new string('s', 70000) }) made ConnectAsync throw InvalidOperationException ("A two-byte
vector cannot hold 70000 bytes.") from TlsWriter.EndVector. A link with a 17 000-character sni=
made it throw ArgumentOutOfRangeException ("A TLS record carries at most 16384 bytes") from the
record layer's WriteAsync. Both happened after the TCP connect, and neither is an exception
ConnectAsync may throw. Separately, alpn=h%C3%A9 was accepted, but TlsClientHello encoded ALPN
with Encoding.ASCII, so the wire carried "h?".
TlsClientHello.TryValidate checks the name and the list against three documented bounds:
- a server name of at most 253 characters in the A-label form SNI sends (a DNS name without
its trailing dot);
- at most 16 ALPN protocols;
- each protocol 1 to 255 bytes of UTF-8 (RFC 7301's one-byte length, which
SslApplicationProtocol also enforces).
At all three maxima the hello comes to about 4.5 KB, which leaves the rest of the 16 384-byte
record as room for a browser fingerprint; Chrome's hello is about 1.7 KB. The same check refuses
a name ToALabel cannot convert, which used to surface from inside the handshake as an
ArgumentException.
The check runs where the configuration enters, like the pbk and sid checks. VlessShareLink
refuses a REALITY link with the reason (FormatException from Parse), and the VlessClient
constructor throws ArgumentException for options built by hand. The name is Sni ?? HostHeader ??
Host, and VlessOptions.RealityServerName now holds that precedence in one place. The parser, the
constructor and BuildRealityOptions all read it, so sni=, host= and the server address are all
bounded. Only security=reality is checked; the TLS path hands the name and ALPN to SslStream.
ALPN is now encoded as UTF-8. That is what SslApplicationProtocol puts on the wire for the same
list under security=tls, and what Go, and so Xray, sends for a string. Refusing non-ASCII instead
would reject, for REALITY only, a link that the TLS path and Xray both accept.
Tests are in VlessTest. Four of them fail against the old library (stashed) on each TFM:
- an over-long name through sni=, host=, Proxy.Create and all three option fields, with 253
characters and an internationalised name still accepted;
- a name IdnMapping cannot convert;
- 17 protocols, a 256-byte one and a 256-byte non-ASCII one refused by the link, and 17
protocols and an empty one refused by the constructor, with 16 protocols of 255 bytes accepted;
- ALPN "hé" encoded as the bytes of SslApplicationProtocol("hé") (the old encoder wrote 02 68 3F).
Reality_HelloAtTheLargestAllowedNameAndAlpn_FitsOneRecord builds a hello at every maximum at
once, checks that one more of each is refused, and writes the hello through the record layer as
one record. It uses the new constants, so it does not compile against the old library and was
left out of that run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
…roxy host The ws and httpupgrade transports write the configured path into the request line, and the Host header value (host=, else sni, else the server address) into the Host header, as they are. A share link percent-decodes both, so path=%2Fws%0D%0AX-Injected:%20yes, or the same in host= or sni=, made VLESS, Trojan and VMess send their node's server, or the CDN in front of it, a header of the link's writing; a path could as well have ended the header block and added a second request. Options built by hand with a CR or LF in Path, HostHeader or Sni did the same. ProxyTransport.TryValidateRequest refuses an ASCII control character (0x00-0x1F, 0x7F) in the path and in the Host header value, for ws and httpupgrade. It runs where the configuration enters: the VLESS and Trojan parsers and both VMess grammars report it as a FormatException, and the three client constructors throw ArgumentException. The message names the field, the code point and the index, never the value, because a path can carry a secret. The raw-TCP transport sends neither value, so a tcp link with junk in an unused path is left working. VlessOptions, TrojanOptions and VmessOptions gain TransportHostHeader, so the check and the request read the same value. A space in the path is not refused; it goes out as %20. A request target cannot hold one (RFC 9112 section 3.2), and a server splitting the request line at it reads the rest as the HTTP version. But a server can be configured with such a path, Xray's client builds this request with Go's net/url, which writes a space in a path as %20, and 68 of the 10 822 vless links in the cached real-world corpus carry one in their decoded ws path. They used to go out as "GET /a b HTTP/1.1". The proxy host: ProxyClient's constructor only length-checked it, and a NUL cuts a name short at the resolver, as 1e33046 measured. It now refuses what ValidateArguments refuses in a target, a space or an ASCII control character, with ArgumentException. Every client, options built by hand and Proxy.Create(ProxyType, ...) come through it. Through links, Uri already refused such a host, raw or percent-encoded, for http, https, socks4, socks4a, socks5, vless, trojan and the vmess URI form. The ss:// authority is scanned by hand and a vmess JSON "add" holds any string, and both built a client with the host as it was; those two parsers now refuse it as a FormatException. An empty Sni on options built by hand was sent as the server name: RealityServerName was Sni ?? HostHeader ?? Host, and the VLESS, Trojan and VMess TLS paths passed the same expression to SslStream, so the hello carried none of the names the options gave. TlsHandshake.ResolveServerName treats an empty sni or host header as absent, as ResolveHostHeader already did, and the options' ServerName (renamed from RealityServerName) now gives REALITY, every TLS TargetHost and the name in a TLS error. A share link never produces an empty value. Tests: 29 new cases on each TFM. Against the old library (stashed, new tests kept) 28 fail on each TFM, each on its own assertion, and Parse_ControlCharacterInFieldsTheTransportNeverSends_IsLeftAlone, which guards against refusing too much, passes. - TransportTest: CR LF, LF, NUL and DEL in path=, host= and sni= refused by the vless, trojan and vmess URI links; CR LF in the path, host and sni of a vmess JSON link, refused by the check rather than as unreadable JSON; NUL, TAB, LF, CR, 0x1F and DEL in Path, HostHeader and Sni refused by all three constructors on both transports. A space in the path goes out as %20 through a link over ws and through built options over httpupgrade; the old request line was "GET /a b?ed=2048 HTTP/1.1". - ProxyFactoryTest: NUL, TAB, LF, CR, 0x1F, space and DEL in the proxy host refused by the HTTP client, Proxy.Create(ProxyType) with and without credentials and the VLESS, Trojan, VMess and Shadowsocks constructors; a NUL or CR LF in the host of all eight link grammars refused as malformed, the vmess JSON and both ss grammars by the new check. - VlessTest, TrojanTest, VmessClientTest: an empty sni falls back to the host header in the REALITY and TLS hellos, and an empty sni and host header to the server address for REALITY. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs
…es on The suite runs the Debug build, so these are live in every test run. Each checks something only a bug in this library can break, and each guards a place where breaking it would not fail on the spot: a pooled buffer rounded up past the size a formula asked for, a zero-length read that reads as end of stream, or an HMAC that takes a key of any length. Nothing a peer, a share link or a caller controls is asserted; those stay throws, because an assert is gone from the Release build. - TlsRecordStream: room in the inbound buffer before a transport read (a 0-byte read would come back as end of stream mid-record, which RealityTlsStream takes for an orderly close); and StageRecord's payload within MaxPlaintext with room for the record (_outboundPlain is rented at 16 385 bytes and comes back at 32 KiB, so an oversized record would be sealed without a word). - TlsRecordProtection's constructor and TlsKeySchedule.ExpandLabel: a secret exactly one hash long. Every caller in the library, the tests and the benchmarks passes one. - ShadowsocksStream: SealRun takes something from a non-empty payload (otherwise WriteAsync loops forever), and FreeSpace leaves the room its own comment promises. - Request sizes the pool's rounding would hide: the SOCKS4 request against BufferSize, the VLESS request against MaxRequestSize (at the write, as length, since BuildRequest itself takes any flow), the Trojan request, the HTTP CONNECT request and the HTTP upgrade request against their computed sizes, and ProxyAddress's destination against MaxLength. Every caller of WriteTypeAndAddress, including ShadowsocksCryptoTest's direct call to BuildAddressHeader, passes at least MaxLength. - VisionStream: room for the read after Compact(1) (a 0-byte read in Undecided mode would switch the stream to Raw and hand the UUID and padding to the caller), a non-zero step when content is taken or padding skipped (otherwise a 0 that reads as end of stream, or a loop that never reads), a whole header before ReadFrameHeader, and the first frame's padding within MaxFrame. - VmessStream: OpenChunk's plaintext length (a mismatch makes the AEAD throw ArgumentException, which the CryptographicException translation lets out of ReadAsync), and SealChunk's plaintext within MaxSendPlaintextSize. - TlsWriter, in Debug only: a stack of the vectors begun and not yet ended. EndVector must end the innermost one, with the prefix size it began with, and ToArray must find none open. The check first suggested, that the prefix being patched still reads zero, misses a size one short (it lands on a zero byte of the same placeholder) and every empty vector. - RealityTlsClient: HandshakeSecrets.At within its six secrets, now a named constant that Rent uses too; and no read or write protection installed yet when the first keys go in. - RealityTlsStream.TakePending: a record in hand and a non-empty buffer (a 0 would read as end of stream). - Sha256Core: whole blocks, an eight-word state and a full schedule for Absorb and Finish (the schedule is expanded through Unsafe.Add, unchecked); an eight-word IV; a digest of whole words that fits its destination. - VmessKdf.Derive: one path element or three. Left out: an assert that the application-epoch protections replace disposed ones. The handshake disposes both on the two lines before, and TlsRecordProtection has no disposed state an assert could read. None were added inside per-byte or per-limb loops (X25519, the SHA rounds, CRC, FNV), where test vectors already pin the behaviour. Verified with the asserts live: dotnet test (Debug) passed 922, skipped 37, of 959 on net10.0 and net11.0, with no DebugAssertException in the log. The library builds with 0 warnings on all four TFMs in Release and in Debug (the TlsWriter check compiles only in Debug), the solution with the two known CA2022 warnings in TlsRecordStreamTest, and tools/CorpusCheck cleanly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…already was Proxy.Create(string) documents FormatException for a link whose scheme is known but which is malformed. "http://" was reported that way, because Uri refuses an http URI with no host. "socks4://", "socks4a://" and "socks5://" were not: Uri takes an empty authority for a scheme it has no rules of its own for, so those links parsed, reached the client constructor as an empty host and left as an ArgumentException. The same broken link changed exception type with its scheme, and ArgumentException is the type the contract keeps for a caller's own mistake. The classic branch of Proxy.Create(string) now refuses a parsed link with an empty host as a FormatException, before any client exists. Proxy.Create(Uri) is unchanged; there the Uri is the caller's own argument. Found while tightening Create_MalformedKnownScheme_ThrowsFormat, which asserted ThrowsAny<Exception> for "socks5://" and so would also have passed on a failed Debug.Assert. It now asserts FormatException for socks4://, socks4a://, socks5:// and http://. Against the old Proxy.cs (stashed, test kept) it fails on both TFMs at socks4://: expected FormatException, actual ArgumentException. Left as it was: "socks5://example.com", with no port, still leaves as an ArgumentOutOfRangeException. Uri reports the missing port as -1 for these schemes and the constructor refuses a negative port, where http:// gets 80 from Uri. Refusing that link as malformed and defaulting to port 1080 are both defensible, and choosing between them is not a fix. Verified: dotnet test (Debug, asserts live) passed 922, skipped 37, of 959 on net10.0 and net11.0. The library builds in Release with 0 warnings on all four TFMs, the solution with the two known CA2022 warnings in TlsRecordStreamTest, and tools/CorpusCheck cleanly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d assert Under dotnet test a failed Debug.Assert is a DebugAssertException thrown into the test that hit it, so a test that accepts any exception passes it. These did. Every row's exception was checked by running it, and none varies: - ProxyFactoryTest.Create_MalformedLink_NeverEchoesTheCredential took ThrowsAny<Exception>; all four links are FormatException. - ProxyFactoryTest.TryCreate_BadLink_ReturnsFalseWithAReason required only that the reason contain "Exception", which "DebugAssertException: ..." does. Each row now names its type: ArgumentException for the empty, blank and scheme-less text, NotSupportedException for hysteria2 and rc4-md5, FormatException for the broken ss userinfo, the vmess payload that is not base64 JSON and the 31-character vless id. - ShadowsocksShareLinkTest.Errors_NeverEchoTheCredential took ThrowsAny<Exception>. Each row now names its type: FormatException for the three malformed authorities, NotSupportedException for the refused cipher, the plugin and the three swapped-field links. - VlessTest.HtmlEscapedRealityLink_StartsATlsHandshake_NotACleartextRequest took ThrowsAny<Exception> around a handshake over an empty MemoryStream, which ends as a RealityHandshakeException. - FactoryTest.BadProtocolUri caught Exception and checked its type, so it could not hide an assert, but it passed when nothing was thrown; it now asserts NotSupportedException. The audit that found these named four sites by line at 8e6166a. VlessTest.cs:281, ProxyFactoryTest.cs:315 and ShadowsocksShareLinkTest.cs:264 are three of the tests above; ProxyFactoryTest.cs:241, Create_MalformedKnownScheme_ThrowsFormat, was tightened in a7ae68e together with the fix it turned up. TryCreate_BadLink_ReturnsFalseWithAReason and FactoryTest.BadProtocolUri were not on that list. The remaining catch blocks in the tests rethrow or filter on named exception types, so a DebugAssertException passes through them. Verified: dotnet test (Debug, asserts live) passed 922, skipped 37, of 959 on net10.0 and net11.0, with every row of the tightened tests passing and no DebugAssertException, and dotnet test -c Release gave the same counts. The library builds in Release with 0 warnings on all four TFMs, the solution with the two known CA2022 warnings in TlsRecordStreamTest, and tools/CorpusCheck cleanly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… may be asserted The library now asserts its internal invariants (ad3d356), and the suite runs the Debug build, so they are live in every test run. AGENTS.md's Testing section says what that means for someone writing a test or an assert: a failed one fails only the test that hit it, and aborts the whole run from a thread nobody awaits; an assert is for an invariant only a bug in this library can break, never for anything a peer, a share link or a caller controls, because it is gone from the Release build; and a test that catches Exception, in whatever form, hides one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An earlier line-ending pass left the file ending in a lone CR, and git's text=auto detection then treated it as binary: stored unnormalised, with its diffs shown as binary. It now ends in CRLF like the rest of the working tree and is stored with LF like every other source file. Line endings only; no content changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HttpResponseParser returned its buffer to ArrayPool.Shared uncleared, both when it grew and on Dispose. After a CONNECT 200 or an HTTP upgrade 101 the buffer holds the tunnel bytes the server sent past the headers, the same class of leak 11c1f81 fixed in RealityTlsStream and WebSocketStream. PrefixedStream.WrapIfNeeded copies OverreadBytes before Dispose, so clearing does not touch data a caller still reads. Verified: dotnet test -c Release with QPN_DOCKER_TESTS=1 and QPN_XRAY_PATH: 969 passed, 3 skipped (external proxy env vars) on net10.0 and net11.0. The 3 DualStackConnectTest cases fail only because this machine refuses a connect to ::1. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
QuickProxyNet.csproj: PackageReleaseNotes describe 5.0.0 instead of 4.0.0, Description and PackageTags name Shadowsocks, and MinVerMinimumMajorMinor is 5.0, so untagged builds version as 5.0.0-alpha. README.md: the error code table gains StringTooLong, TransportUpgradeFailed and TlsHandshakeFailed. A "Many links at once" section shows Proxy.TryCreate and SourceLink. QuickProxyNet/README.md, the package readme, names Shadowsocks, the ss scheme, TryCreate and the EndPoint target. docs/README.md lists shadowsocks.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This release removes
Uriand the read and write timeouts from the public API, and adds Shadowsocks. It also closes two holes in the exception contract ofConnectAsync. A failed TLS handshake and a failed socket creation escaped as raw exceptions instead ofProxyProtocolException. A checker found both while it ran the public API over thousands of real nodes.Breaking changes
Removed from the public API:
ProxyClientFactoryand itsInstance. The sameCreatemethods are now static onProxy. Their parameters are nowproxyLinkandcredentials(before:linkandnetworkCredential).IProxyClient.ProxyUriandProxyClient.ProxyUri. AUricannot hold mostvmess://links. Forvless,trojanandvmessit kept onlyscheme://host:port. For the classic proxies it held the password, so anything that logged it logged the password.Proxy.ConnectAsync(Uri, ...): all three overloads.ProxyUriExtensionswith bothConnectThroughProxyAsyncoverloads.IProxyClient.ReadTimeoutandWriteTimeout, with theirProxyClientimplementations. The library copied them toSocket.ReceiveTimeoutandSendTimeout, which apply only to synchronous calls. Every handshake is asynchronous, so they never bounded a connect.New enum members. An exhaustive
switchneeds adefaultarm:ProxyType.ShadowsocksProxyErrorCode.TlsHandshakeFailedChanged behavior:
SslStreamhandshake now givesProxyProtocolExceptionwithTlsHandshakeFailed. This covers the HTTPS proxy, Trojan, and VLESS or VMess withsecurity=tls. Before, an expired certificate or a refused SNI escaped as a rawAuthenticationException. A REALITY failure is stillRealityHandshakeException.CancellationTokenis nowOperationCanceledExceptionin every phase. Before, the TCP connect reported it asProxyProtocolExceptionwithConnectionFailed. A timeout is stillProxyErrorCode.Timeout.HttpsProxyClientsends the proxy name as SNI and checks the proxy certificate against it. Before, it used the CONNECT target, so every HTTPS proxy failed the default check unless its certificate named the target.http,https,socks4,socks4a,socks5) are now decoded. For example,socks5://user:p%40ss@hostsends the passwordp@ss.ArgumentException. So does such a proxy host in a constructor or inProxy.Create(type, ...). A link with such a host givesFormatException.ArgumentException, from a constructor and from a link.ConnectAsync(Stream, host, port)now checks its arguments like the other overloads. A null host or a port outside 1-65535 givesArgumentException.wsorhttpupgradepath or Host value. A link givesFormatException, and a client constructor givesArgumentException. A space in the path now goes out as%20.pbkorsid, a server name over 253 characters, more than 16 ALPN protocols, or an ALPN protocol that is empty or over 255 bytes. A link givesFormatException, and theVlessClientconstructor givesArgumentException. Before, these failed at connect.SniorHostHeadernow counts as absent. The TLS or REALITY server name falls through to the next value.socks4://,socks4a://andsocks5://links without a host giveFormatException, the same ashttp://.LocalEndPoint. A proxy name with both IPv4 and IPv6 addresses gets both tries, in the order that the resolver returns.ProxyHostis an IPv6 address without brackets, however the host arrived.ObjectDisposedExceptionsynchronously instead of from the returnedValueTask.Migration
ProxyClientFactory.Instance.Create(link)Proxy.Create(link)ProxyClientFactory.Instance.Create(type, host, port, credentials)Proxy.Create(type, host, port, credentials)Proxy.ConnectAsync(uri, host, port, ...)Proxy.ConnectAsync(uri.OriginalString, host, port, ...)Proxy.ConnectAsync(uri, stream, host, port)Proxy.Create(uri).ConnectAsync(stream, host, port)uri.ConnectThroughProxyAsync(host, port, ...)Proxy.Create(uri).ConnectAsync(host, port, ...)client.ProxyUriclient.ToString()for logs,ProxyHostandProxyPortfor the address, orclient.SourceLinkfor the full linkclient.ReadTimeout/client.WriteTimeoutCancellationTokento reads and writes, or setReadTimeout/WriteTimeouton the returned stream when itsCanTimeoutistruecatch (AuthenticationException)aroundConnectAsynccatch (ProxyProtocolException ex) when (ex.ErrorCode == ProxyErrorCode.TlsHandshakeFailed)Proxy.Create(Uri)stays. Use it withWebProxy.AddressorIWebProxy.GetProxy.SourceLinkis set only whenProxy.Create(string)orProxy.TryCreatemade the client. A client from a constructor or fromFromShareLinkhasnullthere.New
ShadowsocksClient,ShadowsocksOptions,ShadowsocksShareLink. The supported ciphers areaes-128-gcm,aes-192-gcm,aes-256-gcmandchacha20-ietf-poly1305. Both link grammars parse: the legacy base64 blob and SIP002. The library refuses every other cipher and everyplugin=by name, before it writes a byte.chacha20-ietf-poly1305needs OS support: Windows 11 or Server 2022, not Windows 10.EndPoint:IProxyClient.ConnectAsync(EndPoint, ...),IProxyClient.ConnectAsync(Stream, EndPoint, ...)andProxy.ConnectAsync(string, EndPoint, ...). This is the shape thatSocketsHttpHandler.ConnectCallbackgives, so anHttpClientgoes through any supported proxy in one line. OnIProxyClientthey are default interface members, so your own implementations get them without a change.Proxy.TryCreate(link, out client)andProxy.TryCreate(link, out client, out error). They never throw, whatever the input. Theerrorstring starts with the exception type, so you can group the refusals of a large subscription.IProxyClient.SourceLink: the link text that made the client. It keeps the uuid, sni and transport thatscheme://host:portloses. It is a default interface member.ProxyClient.ToString()givesscheme://host:portwithout credentials.Fixed
ConnectionFailed.SocksStringTooLong, not a rawOverflowException.ProxyProtocolException, not a rawSocketException.wspath,host=orsni=could inject headers toward the node. The library now refuses all of these.@,#,/or?no longer makes a client constructor orProxy.Create(type, ...)throwUriFormatException.vmess://link with@in the#remarknow parses. An empty"ps"no longer hides the#remark.SslStreamand Xray do.Performance
Not in this release
grpcandxhttptransports, Hysteria2 and TUIC (QUIC), and UDP.docs/reality-fingerprint-plan.md.wsor TLS transport for Shadowsocks.Full Changelog: v4.0.0...v5.0.0
Verification
dotnet build -c Release: the library builds with 0 warnings on net8.0, net9.0, net10.0 and net11.0.dotnet test -c ReleasewithQPN_DOCKER_TESTS=1andQPN_XRAY_PATH(Xray 26.3.27): 969 passed on net10.0 and net11.0. This includes the Docker cases against Xray and sing-box and the REALITY cases against a local Xray.HTTP_PROXY_URI/SOCKS5_PROXY_URI.DualStackConnectTestcases failed locally only because that machine refuses a connect to::1. CI on this PR runs them.🤖 Generated with Claude Code