Export the reviewed application-neutral package set through the exact public allowlist. Development history and private application evidence remain outside this canonical source root. Developed with material AI assistance under maintainer review. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package requestlog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// JSONL is a synchronous append-only sink. The caller owns rotation.
|
||||
type JSONL struct {
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
writer *bufio.Writer
|
||||
err error
|
||||
}
|
||||
|
||||
func OpenJSONL(path string) (*JSONL, error) {
|
||||
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
|
||||
return nil, errors.New("requestlog: JSONL path must be clean and absolute")
|
||||
}
|
||||
if info, err := os.Lstat(path); err == nil && (info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular()) {
|
||||
return nil, errors.New("requestlog: JSONL destination must be a regular file")
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = file.Chmod(0o600); err != nil {
|
||||
file.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &JSONL{file: file, writer: bufio.NewWriterSize(file, 64*1024)}, nil
|
||||
}
|
||||
|
||||
func (sink *JSONL) WriteRecord(ctx context.Context, record Record) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sink.mu.Lock()
|
||||
defer sink.mu.Unlock()
|
||||
if sink.err != nil {
|
||||
return sink.err
|
||||
}
|
||||
if _, err = sink.writer.Write(append(body, '\n')); err == nil {
|
||||
err = sink.writer.Flush()
|
||||
}
|
||||
if err != nil {
|
||||
sink.err = err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (sink *JSONL) Err() error { sink.mu.Lock(); defer sink.mu.Unlock(); return sink.err }
|
||||
|
||||
func (sink *JSONL) Close() error {
|
||||
sink.mu.Lock()
|
||||
defer sink.mu.Unlock()
|
||||
if sink.file == nil {
|
||||
return sink.err
|
||||
}
|
||||
flushErr := sink.writer.Flush()
|
||||
closeErr := sink.file.Close()
|
||||
sink.file = nil
|
||||
return errors.Join(sink.err, flushErr, closeErr)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package requestlog records bounded, versioned HTTP request observations.
|
||||
package requestlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gamertan.com/web/requestmeta"
|
||||
)
|
||||
|
||||
const RecordVersion = 1
|
||||
|
||||
// Record is deliberately stable and append-log friendly. Sensitive fields are
|
||||
// populated only when explicitly enabled by Policy.
|
||||
type Record struct {
|
||||
Version int `json:"version"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Route string `json:"route"`
|
||||
Status int `json:"status"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
DurationMicros int64 `json:"duration_micros"`
|
||||
ClientIP string `json:"client_ip,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
Referer string `json:"referer,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
}
|
||||
|
||||
// Validate rejects records that cannot have been produced by this package's
|
||||
// bounded middleware contract.
|
||||
func (record Record) Validate() error {
|
||||
if record.Version != RecordVersion || record.Timestamp.IsZero() || !boundedField(record.Method, 16, false) || !boundedField(record.Route, 256, false) || record.Status < 100 || record.Status > 999 || record.Bytes < 0 || record.DurationMicros < 0 {
|
||||
return errors.New("requestlog: invalid record")
|
||||
}
|
||||
fields := []struct {
|
||||
value string
|
||||
limit int
|
||||
}{
|
||||
{record.RequestID, 64}, {record.ClientIP, 64}, {record.Path, 2048},
|
||||
{record.Query, 4096}, {record.Referer, 2048}, {record.UserAgent, 1024},
|
||||
{record.SessionID, 256},
|
||||
}
|
||||
for _, field := range fields {
|
||||
if !boundedField(field.value, field.limit, true) {
|
||||
return errors.New("requestlog: invalid record")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sink receives complete records after a handler returns.
|
||||
type Sink interface {
|
||||
WriteRecord(context.Context, Record) error
|
||||
}
|
||||
|
||||
// SensitiveFields must be opted into field by field.
|
||||
type SensitiveFields struct {
|
||||
ClientIP bool
|
||||
Path bool
|
||||
Query bool
|
||||
Referer bool
|
||||
UserAgent bool
|
||||
SessionID bool
|
||||
}
|
||||
|
||||
// Policy controls classification and collection. Route must return a low-cardinality
|
||||
// route label; nil produces "unclassified" rather than recording a raw path.
|
||||
type Policy struct {
|
||||
Route func(*http.Request) string
|
||||
SessionID func(*http.Request) string
|
||||
Sensitive SensitiveFields
|
||||
OnSinkError func(error)
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func Middleware(sink Sink, policy Policy) func(http.Handler) http.Handler {
|
||||
if policy.Now == nil {
|
||||
policy.Now = time.Now
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
started := policy.Now()
|
||||
capture := &responseCapture{ResponseWriter: response, status: http.StatusOK}
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
if recovered != nil && !capture.wroteHeader {
|
||||
capture.status = http.StatusInternalServerError
|
||||
}
|
||||
record := makeRecord(request, capture, policy, started, policy.Now())
|
||||
if sink != nil {
|
||||
if err := sink.WriteRecord(context.WithoutCancel(request.Context()), record); err != nil && policy.OnSinkError != nil {
|
||||
policy.OnSinkError(errors.New("requestlog: sink write failed"))
|
||||
}
|
||||
}
|
||||
if recovered != nil {
|
||||
panic(recovered)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(capture, request)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func makeRecord(request *http.Request, capture *responseCapture, policy Policy, started, finished time.Time) Record {
|
||||
route := "unclassified"
|
||||
if policy.Route != nil {
|
||||
route = bounded(policy.Route(request), 256)
|
||||
if route == "" {
|
||||
route = "unclassified"
|
||||
}
|
||||
}
|
||||
duration := finished.Sub(started).Microseconds()
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
record := Record{Version: RecordVersion, Timestamp: finished.UTC(), Method: bounded(request.Method, 16), Route: route, Status: capture.status, Bytes: capture.bytes, DurationMicros: duration}
|
||||
if metadata, ok := requestmeta.FromContext(request.Context()); ok {
|
||||
record.RequestID = metadata.RequestID
|
||||
if policy.Sensitive.ClientIP && metadata.ClientIP.IsValid() {
|
||||
record.ClientIP = metadata.ClientIP.String()
|
||||
}
|
||||
}
|
||||
if policy.Sensitive.Path {
|
||||
record.Path = bounded(request.URL.EscapedPath(), 2048)
|
||||
}
|
||||
if policy.Sensitive.Query {
|
||||
record.Query = bounded(request.URL.RawQuery, 4096)
|
||||
}
|
||||
if policy.Sensitive.Referer {
|
||||
record.Referer = bounded(request.Referer(), 2048)
|
||||
}
|
||||
if policy.Sensitive.UserAgent {
|
||||
record.UserAgent = bounded(request.UserAgent(), 1024)
|
||||
}
|
||||
if policy.Sensitive.SessionID && policy.SessionID != nil {
|
||||
record.SessionID = bounded(policy.SessionID(request), 256)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func boundedField(value string, limit int, emptyOK bool) bool {
|
||||
if (!emptyOK && value == "") || len(value) > limit || !utf8.ValidString(value) {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character == 0 || character == '\r' || character == '\n' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func bounded(value string, limit int) string {
|
||||
value = strings.ToValidUTF8(value, "�")
|
||||
value = strings.Map(func(r rune) rune {
|
||||
if r == 0 || r == '\r' || r == '\n' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
if len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
value = value[:limit]
|
||||
for !utf8.ValidString(value) {
|
||||
value = value[:len(value)-1]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type responseCapture struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int64
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (capture *responseCapture) WriteHeader(status int) {
|
||||
if capture.wroteHeader {
|
||||
return
|
||||
}
|
||||
capture.wroteHeader = true
|
||||
capture.status = status
|
||||
capture.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (capture *responseCapture) Write(body []byte) (int, error) {
|
||||
if !capture.wroteHeader {
|
||||
capture.WriteHeader(http.StatusOK)
|
||||
}
|
||||
written, err := capture.ResponseWriter.Write(body)
|
||||
capture.bytes += int64(written)
|
||||
return written, err
|
||||
}
|
||||
|
||||
func (capture *responseCapture) Unwrap() http.ResponseWriter { return capture.ResponseWriter }
|
||||
@@ -0,0 +1,169 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package requestlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/requestmeta"
|
||||
)
|
||||
|
||||
type memorySink struct {
|
||||
records []Record
|
||||
err error
|
||||
ctxErr error
|
||||
}
|
||||
|
||||
func (sink *memorySink) WriteRecord(ctx context.Context, record Record) error {
|
||||
sink.records = append(sink.records, record)
|
||||
sink.ctxErr = ctx.Err()
|
||||
return sink.err
|
||||
}
|
||||
|
||||
func TestSafePolicyOmitsSensitiveFields(t *testing.T) {
|
||||
resolver, _ := requestmeta.New(requestmeta.Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}, Random: strings.NewReader(strings.Repeat("a", 16))})
|
||||
sink := &memorySink{}
|
||||
now := time.Unix(100, 0)
|
||||
handler := resolver.Middleware(Middleware(sink, Policy{Route: func(*http.Request) string { return "item.show" }, Now: func() time.Time { now = now.Add(time.Millisecond); return now }})(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.WriteHeader(http.StatusCreated)
|
||||
_, _ = response.Write([]byte("ok"))
|
||||
})))
|
||||
request := httptest.NewRequest(http.MethodGet, "http://example.test/private?id=secret", nil)
|
||||
request.RemoteAddr = "127.0.0.1:1000"
|
||||
request.Header.Set("X-Forwarded-For", "203.0.113.9")
|
||||
request.Header.Set("User-Agent", "private-agent")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if len(sink.records) != 1 {
|
||||
t.Fatalf("records=%d", len(sink.records))
|
||||
}
|
||||
record := sink.records[0]
|
||||
if record.Route != "item.show" || record.Status != 201 || record.Bytes != 2 || record.RequestID == "" {
|
||||
t.Fatalf("record=%+v", record)
|
||||
}
|
||||
if record.ClientIP != "" || record.Path != "" || record.Query != "" || record.UserAgent != "" {
|
||||
t.Fatalf("sensitive leak: %+v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSensitivePolicyIsExplicitAndBounded(t *testing.T) {
|
||||
sink := &memorySink{}
|
||||
handler := Middleware(sink, Policy{Sensitive: SensitiveFields{Path: true, Query: true, Referer: true, UserAgent: true, SessionID: true}, SessionID: func(*http.Request) string { return "session" }})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
request := httptest.NewRequest(http.MethodGet, "http://example.test/path?q=value", nil)
|
||||
request.Header.Set("Referer", "https://ref.example/")
|
||||
request.Header.Set("User-Agent", "browser")
|
||||
handler.ServeHTTP(httptest.NewRecorder(), request)
|
||||
record := sink.records[0]
|
||||
if record.Path != "/path" || record.Query != "q=value" || record.Referer == "" || record.UserAgent != "browser" || record.SessionID != "session" {
|
||||
t.Fatalf("record=%+v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONLRoundTripAndMode(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "access.jsonl")
|
||||
sink, err := OpenJSONL(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = sink.WriteRecord(context.Background(), Record{Version: 1, Timestamp: time.Unix(100, 0), Method: "GET", Route: "home", Status: 200}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = sink.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("mode=%o", info.Mode().Perm())
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var record Record
|
||||
if err = json.Unmarshal([]byte(strings.TrimSpace(string(body))), &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.Route != "home" || record.Version != 1 {
|
||||
t.Fatalf("record=%+v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanicIsRecordedAndRepanicked(t *testing.T) {
|
||||
sink := &memorySink{}
|
||||
handler := Middleware(sink, Policy{})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("expected") }))
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("panic was swallowed")
|
||||
}
|
||||
if len(sink.records) != 1 || sink.records[0].Status != http.StatusInternalServerError {
|
||||
t.Fatalf("records=%+v", sink.records)
|
||||
}
|
||||
}()
|
||||
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "http://example.test/", nil))
|
||||
}
|
||||
|
||||
func TestCanceledRequestStillRecordsEvidence(t *testing.T) {
|
||||
sink := &memorySink{}
|
||||
handler := Middleware(sink, Policy{})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
|
||||
ctx, cancel := context.WithCancel(request.Context())
|
||||
cancel()
|
||||
handler.ServeHTTP(httptest.NewRecorder(), request.WithContext(ctx))
|
||||
if len(sink.records) != 1 || sink.ctxErr != nil {
|
||||
t.Fatalf("records=%d ctxErr=%v", len(sink.records), sink.ctxErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONLRejectsInvalidRecord(t *testing.T) {
|
||||
sink, err := OpenJSONL(filepath.Join(t.TempDir(), "access.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sink.Close()
|
||||
if err = sink.WriteRecord(context.Background(), Record{Version: 99}); err == nil {
|
||||
t.Fatal("invalid record accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONLRejectsSymlinkDestination(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation is privilege-dependent on Windows")
|
||||
}
|
||||
directory := t.TempDir()
|
||||
target := filepath.Join(directory, "target.jsonl")
|
||||
if err := os.WriteFile(target, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(directory, "access.jsonl")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := OpenJSONL(link); err == nil {
|
||||
t.Fatal("symlink destination accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseStatusUsesFirstHeader(t *testing.T) {
|
||||
sink := &memorySink{}
|
||||
handler := Middleware(sink, Policy{})(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
response.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "http://example.test/", nil))
|
||||
if sink.records[0].Status != http.StatusNoContent {
|
||||
t.Fatalf("status=%d", sink.records[0].Status)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user