From a18f1dd22a448055da1b232ffa9fb664b2f4501b Mon Sep 17 00:00:00 2001 From: Cole Speelman Date: Sun, 16 Aug 2026 19:20:14 -0400 Subject: [PATCH] security: harden first preview boundaries Sanitized snapshot of private source 13a965dd6ea705dd92499f7dbeaa00c25c15247d. Require same-origin evidence for unsafe methods, fail closed on invalid authentication middleware configuration, and bound untrusted request metadata. AI-Assistance: OpenAI Codex assisted implementation, testing, and security review. Signed-off-by: Cole Speelman --- CHANGELOG.md | 9 ++++++-- README.md | 18 ++++++++++++++-- auth/auth.go | 3 +++ auth/service_test.go | 24 +++++++++++++++++++++ authhttp/authhttp.go | 13 +++++++++-- authhttp/authhttp_test.go | 38 +++++++++++++++++++++++++++++++++ docs/THREAT_MODEL.md | 5 +++++ requestlog/requestlog.go | 7 +++++- requestlog/requestlog_test.go | 14 ++++++++++++ requestmeta/requestmeta.go | 2 +- requestmeta/requestmeta_test.go | 10 +++++++++ websec/websec.go | 17 +++++++++++---- websec/websec_test.go | 33 ++++++++++++++++++++++++++++ 13 files changed, 181 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 138b92f..f02e98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,15 @@ # Changelog -## Unreleased +## v0.1.0-preview.1 — 2026-08-16 - Establish independent request metadata, logging, browser security, abuse, authentication, SQLite, and analytics package boundaries. - Add a minimal 0BSD `net/http` starter. +- Fail closed when unsafe requests lack same-origin evidence or authentication + middleware is constructed with invalid cookie/service configuration. +- Bound untrusted request-record byte and duration fields before aggregation. +- Support Linux as the maintained release platform; native Windows is not a + release gate or compatibility promise. -No compatibility promise is made before the first preview tag. +No compatibility promise is made before a stable release. diff --git a/README.md b/README.md index e83f80e..332777c 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ # Gamertan Web Foundations -> Status: unreleased development work toward `v0.1.0-preview.1`. No install -> coordinate is promised until the reviewed public snapshot is tagged. +> Status: `v0.1.0-preview.1` public preview. APIs may change before a stable +> release; Linux is the maintained release platform. Small, composable Go packages for the unglamorous boundaries of a careful web application: request identity, structured request logs, browser security, @@ -17,6 +17,20 @@ The first preview targets modest Linux servers, local files, SQLite, and normal Go binaries. It requires no Redis, message broker, hosted identity provider, telemetry service, or JavaScript framework. +## Install + +Pin the preview in an application module, then import only the packages that +application needs: + +```bash +go get gamertan.com/web@v0.1.0-preview.1 +go mod verify +``` + +Canonical source, issues, security policy, and release notes live on +[Gamertan Gitea](https://gitea.speelman.ca/gamertan/web). GitHub is a read-only +discovery snapshot rather than a second release origin. + Linux is the required and supported release platform. WSL may be used as a Linux development environment. Native Windows is not a release gate or support promise; downstream users may evaluate the ordinary Go packages elsewhere diff --git a/auth/auth.go b/auth/auth.go index 75f2f92..49f1f2e 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -192,6 +192,9 @@ func (service *Service) Session(ctx context.Context, token string) (Principal, e } func (service *Service) RevokeSession(ctx context.Context, token string) error { + if len(token) < 32 || len(token) > 128 { + return ErrSessionNotFound + } digest := sha256.Sum256([]byte(token)) return service.repository.DeleteSession(ctx, digest) } diff --git a/auth/service_test.go b/auth/service_test.go index c65e2ba..35822d1 100644 --- a/auth/service_test.go +++ b/auth/service_test.go @@ -29,6 +29,30 @@ func TestSessionDistinguishesMissingFromUnavailableStorage(t *testing.T) { } } +func TestRevokeSessionRejectsInvalidTokenBeforeStorage(t *testing.T) { + repository := &recordingRepository{} + service, err := New(repository, Options{}) + if err != nil { + t.Fatal(err) + } + if err = service.RevokeSession(t.Context(), "short"); !errors.Is(err, ErrSessionNotFound) { + t.Fatalf("err=%v", err) + } + if repository.deleted { + t.Fatal("storage called for invalid token") + } +} + +type recordingRepository struct { + repositoryStub + deleted bool +} + +func (repository *recordingRepository) DeleteSession(context.Context, [32]byte) error { + repository.deleted = true + return nil +} + type repositoryStub struct{ sessionErr error } func (repositoryStub) CreateUser(context.Context, User, string) error { return nil } diff --git a/authhttp/authhttp.go b/authhttp/authhttp.go index 56d0797..28cc22b 100644 --- a/authhttp/authhttp.go +++ b/authhttp/authhttp.go @@ -40,7 +40,7 @@ func SetSession(response http.ResponseWriter, config CookieConfig, token string, if err := config.Validate(); err != nil { return err } - if token == "" || len(token) > 128 { + if len(token) < 32 || len(token) > 128 { return errors.New("authhttp: invalid session token") } sameSite := config.SameSite @@ -64,16 +64,25 @@ func ClearSession(response http.ResponseWriter, config CookieConfig) error { } func SessionToken(request *http.Request, config CookieConfig) (string, bool) { + if config.Validate() != nil { + return "", false + } cookie, err := request.Cookie(config.Name) - if err != nil || cookie.Value == "" || len(cookie.Value) > 128 { + if err != nil || len(cookie.Value) < 32 || len(cookie.Value) > 128 { return "", false } return cookie.Value, true } func Optional(service *auth.Service, config CookieConfig) func(http.Handler) http.Handler { + configurationValid := service != nil && config.Validate() == nil return func(next http.Handler) http.Handler { return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if !configurationValid { + response.Header().Set("Cache-Control", "no-store") + http.Error(response, "authentication unavailable", http.StatusServiceUnavailable) + return + } if token, ok := SessionToken(request, config); ok { if principal, err := service.Session(request.Context(), token); err == nil { request = request.WithContext(auth.WithPrincipal(request.Context(), principal)) diff --git a/authhttp/authhttp_test.go b/authhttp/authhttp_test.go index 0f65caf..cbc1050 100644 --- a/authhttp/authhttp_test.go +++ b/authhttp/authhttp_test.go @@ -36,6 +36,13 @@ func TestCookieRequiresHostPrefix(t *testing.T) { } } +func TestSessionCookieRejectsShortToken(t *testing.T) { + config := CookieConfig{Name: "__Host-app_session", Lifetime: time.Hour} + if err := SetSession(httptest.NewRecorder(), config, "predictable", time.Unix(100, 0)); err == nil { + t.Fatal("short session token accepted") + } +} + func TestCSRFUsesSessionAndPurpose(t *testing.T) { token := strings.Repeat("s", 43) csrf, err := CSRFToken(token, "profile:update") @@ -65,6 +72,37 @@ func TestOptionalFailsClosedWhenSessionStorageIsUnavailable(t *testing.T) { } } +func TestOptionalFailsClosedWhenConfigurationIsInvalid(t *testing.T) { + for _, test := range []struct { + name string + service *auth.Service + config CookieConfig + }{ + {name: "nil service", config: CookieConfig{Name: "__Host-app_session", Lifetime: time.Hour}}, + {name: "invalid cookie", service: mustAuthService(t), config: CookieConfig{Name: "session", Lifetime: time.Hour}}, + } { + t.Run(test.name, func(t *testing.T) { + handler := Optional(test.service, test.config)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("handler ran with invalid authentication configuration") + })) + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "https://example.test/", nil)) + if response.Code != http.StatusServiceUnavailable || response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("status=%d cache=%q", response.Code, response.Header().Get("Cache-Control")) + } + }) + } +} + +func mustAuthService(t *testing.T) *auth.Service { + t.Helper() + service, err := auth.New(authHTTPRepository{}, auth.Options{}) + if err != nil { + t.Fatal(err) + } + return service +} + type authHTTPRepository struct{ err error } func (authHTTPRepository) CreateUser(context.Context, auth.User, string) error { return nil } diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 3ad576d..48a27b6 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -12,6 +12,11 @@ and session identifiers, digest-only session storage, Argon2id passwords, constant-time comparisons, same-origin and CSRF primitives, fail-closed storage errors, and separate safe/sensitive analytics projections. +Unsafe methods without an exact Origin or trustworthy same-origin Fetch +Metadata fail the origin check. Authentication middleware fails closed when its +service or `__Host-` cookie policy is invalid. Imported request records have +bounded byte and duration fields before analytics sums them. + The toolkit does not sandbox application handlers, secure an incorrectly configured reverse proxy, authorize application routes automatically, encrypt a compromised host, or decide how long an operator may lawfully retain personal diff --git a/requestlog/requestlog.go b/requestlog/requestlog.go index ca99899..9401d1f 100644 --- a/requestlog/requestlog.go +++ b/requestlog/requestlog.go @@ -16,6 +16,11 @@ import ( const RecordVersion = 1 +const ( + maxRecordBytes int64 = 1 << 40 + maxRecordDurationMicros int64 = int64((7 * 24 * time.Hour) / time.Microsecond) +) + // Record is deliberately stable and append-log friendly. Sensitive fields are // populated only when explicitly enabled by Policy. type Record struct { @@ -38,7 +43,7 @@ type Record struct { // 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 { + 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.Bytes > maxRecordBytes || record.DurationMicros < 0 || record.DurationMicros > maxRecordDurationMicros { return errors.New("requestlog: invalid record") } fields := []struct { diff --git a/requestlog/requestlog_test.go b/requestlog/requestlog_test.go index 24790c2..b6b5ea2 100644 --- a/requestlog/requestlog_test.go +++ b/requestlog/requestlog_test.go @@ -138,6 +138,20 @@ func TestJSONLRejectsInvalidRecord(t *testing.T) { } } +func TestRecordRejectsUnboundedNumericFields(t *testing.T) { + base := Record{Version: RecordVersion, Timestamp: time.Unix(100, 0), Method: "GET", Route: "home", Status: 200} + tooManyBytes := base + tooManyBytes.Bytes = maxRecordBytes + 1 + if err := tooManyBytes.Validate(); err == nil { + t.Fatal("unbounded byte count accepted") + } + tooLong := base + tooLong.DurationMicros = maxRecordDurationMicros + 1 + if err := tooLong.Validate(); err == nil { + t.Fatal("unbounded duration accepted") + } +} + func TestJSONLRejectsSymlinkDestination(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlink creation is privilege-dependent on Windows") diff --git a/requestmeta/requestmeta.go b/requestmeta/requestmeta.go index 296b9d6..eed2aea 100644 --- a/requestmeta/requestmeta.go +++ b/requestmeta/requestmeta.go @@ -191,7 +191,7 @@ func parseForwardedFor(value string) ([]netip.Addr, error) { result := make([]netip.Addr, 0, len(parts)) for _, part := range parts { address, err := netip.ParseAddr(strings.TrimSpace(part)) - if err != nil { + if err != nil || address.Zone() != "" { return nil, ErrInvalidForwarding } result = append(result, address.Unmap()) diff --git a/requestmeta/requestmeta_test.go b/requestmeta/requestmeta_test.go index 920b3f1..6093b8f 100644 --- a/requestmeta/requestmeta_test.go +++ b/requestmeta/requestmeta_test.go @@ -56,6 +56,16 @@ func TestResolverRejectsMalformedTrustedForwarding(t *testing.T) { } } +func TestResolverRejectsForwardedIPv6Zone(t *testing.T) { + resolver, _ := New(Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}, Random: strings.NewReader(strings.Repeat("c", 16))}) + request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) + request.RemoteAddr = "127.0.0.1:1234" + request.Header.Set("X-Forwarded-For", "fe80::1%eth0") + if _, err := resolver.Resolve(request); !errors.Is(err, ErrInvalidForwarding) { + t.Fatalf("err=%v", err) + } +} + func TestResolverRejectsAmbiguousTrustedForwarding(t *testing.T) { resolver, _ := New(Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}, Random: strings.NewReader(strings.Repeat("d", 16))}) request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) diff --git a/websec/websec.go b/websec/websec.go index f074b7b..6e95f8f 100644 --- a/websec/websec.go +++ b/websec/websec.go @@ -76,19 +76,28 @@ func RequireHTTPS(next http.Handler) http.Handler { // SameOrigin accepts browser requests that are demonstrably same-origin. It // rejects contradictory fetch metadata even when Origin is absent. func SameOrigin(request *http.Request, allowedOrigin string) bool { - if site := strings.ToLower(strings.TrimSpace(request.Header.Get("Sec-Fetch-Site"))); site != "" && site != "same-origin" && site != "none" { + site := strings.ToLower(strings.TrimSpace(request.Header.Get("Sec-Fetch-Site"))) + if site != "" && site != "same-origin" && site != "none" { return false } origin := strings.TrimSpace(request.Header.Get("Origin")) if origin == "" { - return true + if site == "same-origin" || site == "none" { + return true + } + switch request.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return true + default: + return false + } } want, err := url.Parse(allowedOrigin) - if err != nil || want.Scheme == "" || want.Host == "" || want.Path != "" { + if err != nil || want.Scheme == "" || want.Host == "" || want.Path != "" || want.RawQuery != "" || want.Fragment != "" || want.User != nil { return false } got, err := url.Parse(origin) - return err == nil && strings.EqualFold(got.Scheme, want.Scheme) && strings.EqualFold(got.Host, want.Host) && got.Path == "" && got.RawQuery == "" && got.Fragment == "" + return err == nil && got.User == nil && strings.EqualFold(got.Scheme, want.Scheme) && strings.EqualFold(got.Host, want.Host) && got.Path == "" && got.RawQuery == "" && got.Fragment == "" } // CSRFToken binds a purpose to opaque session secret material. diff --git a/websec/websec_test.go b/websec/websec_test.go index 5aa8478..2155d07 100644 --- a/websec/websec_test.go +++ b/websec/websec_test.go @@ -27,6 +27,39 @@ func TestSameOriginRejectsCrossSiteAndContradiction(t *testing.T) { } } +func TestSameOriginRequiresEvidenceForUnsafeRequests(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "https://example.test/change", nil) + if SameOrigin(request, "https://example.test") { + t.Fatal("unsafe request without origin evidence accepted") + } + request.Header.Set("Sec-Fetch-Site", "same-origin") + if !SameOrigin(request, "https://example.test") { + t.Fatal("same-origin fetch metadata rejected") + } + request = httptest.NewRequest(http.MethodGet, "https://example.test/read", nil) + if !SameOrigin(request, "https://example.test") { + t.Fatal("safe request without browser metadata rejected") + } +} + +func TestSameOriginRejectsMalformedConfiguredAndPresentedOrigins(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "https://example.test/change", nil) + request.Header.Set("Origin", "https://example.test") + for _, allowed := range []string{ + "https://user@example.test", + "https://example.test?scope=wrong", + "https://example.test#wrong", + } { + if SameOrigin(request, allowed) { + t.Fatalf("configured origin %q accepted", allowed) + } + } + request.Header.Set("Origin", "https://user@example.test") + if SameOrigin(request, "https://example.test") { + t.Fatal("origin containing user information accepted") + } +} + func TestCSRFIsPurposeBound(t *testing.T) { secret := []byte("0123456789abcdef0123456789abcdef") token, err := CSRFToken(secret, "account:update")