requestlog: preserve audited connection upgrades
verify / verify (push) Successful in 3m26s

This commit is contained in:
2026-08-28 13:38:35 -04:00
parent a769d1ea7b
commit 7c8f0d708e
4 changed files with 63 additions and 4 deletions
+6 -1
View File
@@ -2,7 +2,12 @@
# Changelog # Changelog
## Unreleased ## v0.1.0-preview.8 — 2026-08-28
- Preserve `http.Hijacker` through the request-evidence middleware so audited,
authenticated WebSocket and other HTTP upgrade handlers can operate without
bypassing request logging. Successful upgrades are recorded as HTTP 101;
upgraded-protocol bytes remain outside HTTP body-byte accounting.
- Add revisioned active/archived lifecycles for organizations and teams, - Add revisioned active/archived lifecycles for organizations and teams,
invitation listing and revocation, membership suspension/removal, team-member invitation listing and revocation, membership suspension/removal, team-member
+3 -3
View File
@@ -2,7 +2,7 @@
# Gamertan Web Foundations # Gamertan Web Foundations
> Status: `v0.1.0-preview.7` public preview. APIs may change before a stable > Status: `v0.1.0-preview.8` public preview. APIs may change before a stable
> release; Linux is the maintained release platform. > release; Linux is the maintained release platform.
Small, composable Go packages for the unglamorous boundaries of a careful web Small, composable Go packages for the unglamorous boundaries of a careful web
@@ -24,14 +24,14 @@ Pin the preview in an application module, then import only the packages that
application needs: application needs:
```bash ```bash
go get gamertan.com/web@v0.1.0-preview.7 go get gamertan.com/web@v0.1.0-preview.8
go mod verify go mod verify
``` ```
An application may also name the first package it intends to adopt: An application may also name the first package it intends to adopt:
```bash ```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.7 go get gamertan.com/web/requestmeta@v0.1.0-preview.8
``` ```
The version belongs to the `gamertan.com/web` module. Go compiles and links The version belongs to the `gamertan.com/web` module. Go compiles and links
+16
View File
@@ -4,8 +4,10 @@
package requestlog package requestlog
import ( import (
"bufio"
"context" "context"
"errors" "errors"
"net"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -208,3 +210,17 @@ func (capture *responseCapture) Write(body []byte) (int, error) {
} }
func (capture *responseCapture) Unwrap() http.ResponseWriter { return capture.ResponseWriter } func (capture *responseCapture) Unwrap() http.ResponseWriter { return capture.ResponseWriter }
// Hijack preserves connection-upgrade support through the request evidence
// wrapper. A successful upgrade is recorded as HTTP 101; bytes exchanged after
// hijacking belong to the upgraded protocol and are intentionally not counted
// as HTTP response-body bytes.
func (capture *responseCapture) Hijack() (net.Conn, *bufio.ReadWriter, error) {
connection, buffer, err := http.NewResponseController(capture.ResponseWriter).Hijack()
if err != nil {
return nil, nil, err
}
capture.wroteHeader = true
capture.status = http.StatusSwitchingProtocols
return connection, buffer, nil
}
+38
View File
@@ -3,8 +3,10 @@
package requestlog package requestlog
import ( import (
"bufio"
"context" "context"
"encoding/json" "encoding/json"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
@@ -24,6 +26,16 @@ type memorySink struct {
ctxErr error ctxErr error
} }
type hijackableRecorder struct {
*httptest.ResponseRecorder
connection net.Conn
buffer *bufio.ReadWriter
}
func (recorder *hijackableRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return recorder.connection, recorder.buffer, nil
}
func (sink *memorySink) WriteRecord(ctx context.Context, record Record) error { func (sink *memorySink) WriteRecord(ctx context.Context, record Record) error {
sink.records = append(sink.records, record) sink.records = append(sink.records, record)
sink.ctxErr = ctx.Err() sink.ctxErr = ctx.Err()
@@ -208,3 +220,29 @@ func TestResponseStatusUsesFirstHeader(t *testing.T) {
t.Fatalf("status=%d", sink.records[0].Status) t.Fatalf("status=%d", sink.records[0].Status)
} }
} }
func TestResponseCapturePreservesConnectionHijacking(t *testing.T) {
serverConnection, clientConnection := net.Pipe()
defer serverConnection.Close()
defer clientConnection.Close()
underlying := &hijackableRecorder{
ResponseRecorder: httptest.NewRecorder(),
connection: serverConnection,
buffer: bufio.NewReadWriter(bufio.NewReader(serverConnection), bufio.NewWriter(serverConnection)),
}
capture := &responseCapture{ResponseWriter: underlying, status: http.StatusOK}
hijacker, ok := any(capture).(http.Hijacker)
if !ok {
t.Fatal("request evidence wrapper does not expose http.Hijacker")
}
connection, buffer, err := hijacker.Hijack()
if err != nil {
t.Fatal(err)
}
if connection != serverConnection || buffer != underlying.buffer {
t.Fatal("hijacked connection was not passed through")
}
if capture.status != http.StatusSwitchingProtocols || !capture.wroteHeader || capture.bytes != 0 {
t.Fatalf("capture after hijack=%+v", capture)
}
}