Skip to content

Comments

protocol: don't buffer underlying conn reads after parsing header#148

Merged
pires merged 5 commits intomainfrom
pires/fix/buffer_header_only
Feb 5, 2026
Merged

protocol: don't buffer underlying conn reads after parsing header#148
pires merged 5 commits intomainfrom
pires/fix/buffer_header_only

Conversation

@pires
Copy link
Owner

@pires pires commented Feb 4, 2026

Fixes #120
Fixes #147
Refs #142

Please, critique @clementnuss @emersion.

@coveralls
Copy link

Coverage Status

coverage: 95.088% (+0.06%) from 95.03%
when pulling 35b71de on pires/fix/buffer_header_only
into 1ddde3c on main.

@pires pires force-pushed the pires/fix/buffer_header_only branch from 35b71de to 9321c93 Compare February 4, 2026 13:45
Comment on lines +2030 to +2042
for len(readPayload) < payloadSize {
_ = conn.SetReadDeadline(time.Now().Add(time.Second))
n, err := conn.Read(buf)
if err != nil && err != io.EOF {
t.Fatalf("unexpected read error: %v", err)
}
if n > 0 {
readPayload = append(readPayload, buf[:n]...)
}
if err == io.EOF {
break
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test is also passing with the previous MultiReader approach (although back then we always used the bufferedReader and never directly the conn, but that is also not tested).

If you want to stick with adhering strictly to the reader contract, I would modify this test so that we actually validate that we reach the underlying Conn (I can do that if you let me modify your branch). I.e. so that this test fails on the earlier version of the code. the modification amounts to doing a double-read with a 4KiB buffer and using a payload like 1KiB:

  1. we receive the buffered bytes
  2. we receive the remaining bytes with the following Read() call.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to open a PR against this PR. Since I'm working directly on this repo, I would have to give you write permissions to it so you could write to this branch, which is not ideal.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC the test fails with the earlier version of the code but passes now as documented in #148 (comment).

protocol.go Outdated
Comment on lines 155 to 164
bufReader: br,
reader: io.MultiReader(br, conn),
conn: conn,
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you really don't want to slightly break the Reader Contract, then I would ask you to increase this bufSize to the default of 4 KiB (i.e. do a bufio.NewReader(conn)). this would mimic the old armon/go-proxyproto behavior, solve ingress nginx issue and potentially other users of this library.

in using this library though I'm expecting to transparently hit the underlying conn. therefore, when making a call to pConn.Read() with a 4KiB buffer, I am not at all expecting that the library will only return 210 bytes.
the alternatives are not compelling either: io.ReadAll would be reading too many bytes and io.ReadFull would return ErrUnexpectedEOF if there's an EOF before my buffer is filled.

if I want to mimic the "transparent" behavior (i.e. providing a 4KiB buffer and receiving 4KiB if those are available on the OS buffers), I'll have to make double Read calls as a user, to first drain the bufferedReader and then reach the actual Conn.
This workaround is both impractical and counter-intuitive, as I would expect from a library that transparently pipes an underlying conn to transparently give me access to the underlying conn, but at least with your fix I would actually reach the underlying Conn and this solution would work (with the previous version that would've been impossible)

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, see #150 for my preferred ways to deal with headers larger than 256 bytes.

Wrt to the Read(b) contract, the Go documentation is clear that it should return data available and not wait for b to be full. I will sleep over this and come back to you very soon.

Copy link
Owner Author

@pires pires Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

io.ReadFull would return ErrUnexpectedEOF if there's an EOF before my buffer is filled.

IIUC EOF would only be returned if the underlying net.Conn.Read(b) returns EOF, right?

That said, I think we're trying to address different challenges:

  1. stop buffering the underlying conn after the the header is processed and the buffer is drained (fixed in this PR)

  2. strictly follow io.Reader contract or deliver a different undertanding, ie Read(b) will block until b is full or EOF is returned

  3. increase the buffer size statically (eg Bump maximum header size to 4KiB, alternative version #155) or dinamically (protocol: safely resize header buffer based on length field #150) Bump maximum header size to 4KiB, alternative version #155 IS NOT about resizing the buffer BUT it does increase the v2 header size limit to 4KB. I have closed protocol: safely resize header buffer based on length field #150

This PR is meant to fix the 1 alone. Meanwhile, I enabled discussions for this repo so we can have a discussion wrt 2. cc @emersion

Copy link
Owner Author

@pires pires Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created discussion #157 for debating 2.

@pires
Copy link
Owner Author

pires commented Feb 5, 2026

The test added by @clementnuss before the fix:

=== RUN   TestConnReadTruncatesData
    protocol_test.go:2419: Sent: 400 bytes payload (after 46 byte PROXY header)
    protocol_test.go:2420: Read: 210 bytes
    protocol_test.go:2423: BUG: Read returned 210 bytes, expected 400 (lost 190 bytes)
--- FAIL: TestConnReadTruncatesData (0.00s)

After the fix (which also renames test from TestConnReadTruncatesData to TestConnReadHandlesChunkedPayload:

=== RUN   TestConnReadHandlesChunkedPayload
    protocol_test.go:2431: Sent: 400 bytes payload (after 46 byte PROXY header)
    protocol_test.go:2432: Read: 400 bytes
--- PASS: TestConnReadHandlesChunkedPayload (0.00s)

@pires pires force-pushed the pires/fix/buffer_header_only branch from 9321c93 to d3a4ad1 Compare February 5, 2026 12:52
Copy link
Contributor

@emersion emersion left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This idea LGTM!

@emersion
Copy link
Contributor

emersion commented Feb 5, 2026

Oh, actually, I think we need to add an extra check to the WriteTo impl. As-is, it may panic accessing an nil p.bufReader.

@pires
Copy link
Owner Author

pires commented Feb 5, 2026

Oh, actually, I think we need to add an extra check to the WriteTo impl. As-is, it may panic accessing an nil p.bufReader.

Sweet lord, nice catch!

@pires
Copy link
Owner Author

pires commented Feb 5, 2026

@emersion added tests for both read/write after bufReader is set to nil and fixed.

Here's the write test before the fix:

=== RUN   TestWriteToUsesConnWhenBufReaderNil
--- FAIL: TestWriteToUsesConnWhenBufReaderNil (0.00s)
panic: runtime error: invalid memory address or nil pointer dereference [recovered, repanicked]
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x10454d62c]

goroutine 225 [running]:
testing.tRunner.func1.2({0x104707f00, 0x104a2d480})
(...)
FAIL	github.com/pires/go-proxyproto	5.813s
	github.com/pires/go-proxyproto/examples/client		coverage: 0.0% of statements
	github.com/pires/go-proxyproto/examples/httpserver		coverage: 0.0% of statements
	github.com/pires/go-proxyproto/examples/server		coverage: 0.0% of statements

clementnuss and others added 5 commits February 5, 2026 15:39
there's a severe bug in go-proxyproto, making it so that Conns wrapped
by this library provide truncated data to the upstream.
the core issue is that a MultiReader only concatenates it's Input
Readers when those return an EOF. If an underlying input reader returns
a number of bytes (because data was buffered, e.g. after a Peek()), then
the MultiReader abstraction will not read data from the other
InputReaders.

this e.g. impacts ingress-nginx v1.14.x and breaks TLS passtrough in
some scenarios, typically when the combined PROXY protocol header and
TLS header are > 256 bytes.

here's the core issue at play on the go playground:
https://go.dev/play/p/wd2VVCvOPb_7
@pires pires force-pushed the pires/fix/buffer_header_only branch from 590b137 to 1dabac2 Compare February 5, 2026 15:40
@pires
Copy link
Owner Author

pires commented Feb 5, 2026

Rebased to include #155.

@pires pires merged commit d2d7dc3 into main Feb 5, 2026
8 checks passed
@pires pires deleted the pires/fix/buffer_header_only branch February 5, 2026 15:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

protocol: io.MultiReader stuck on bufio.Reader.Read(b)

4 participants