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 <crspeelman@gmail.com>
This commit is contained in:
2026-08-16 19:20:14 -04:00
parent df946d888e
commit f1adaaba21
13 changed files with 181 additions and 12 deletions
+7 -2
View File
@@ -2,10 +2,15 @@
# Changelog # Changelog
## Unreleased ## v0.1.0-preview.1 — 2026-08-16
- Establish independent request metadata, logging, browser security, abuse, - Establish independent request metadata, logging, browser security, abuse,
authentication, SQLite, and analytics package boundaries. authentication, SQLite, and analytics package boundaries.
- Add a minimal 0BSD `net/http` starter. - 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.
+16 -2
View File
@@ -2,8 +2,8 @@
# Gamertan Web Foundations # Gamertan Web Foundations
> Status: unreleased development work toward `v0.1.0-preview.1`. No install > Status: `v0.1.0-preview.1` public preview. APIs may change before a stable
> coordinate is promised until the reviewed public snapshot is tagged. > 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
application: request identity, structured request logs, browser security, 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, Go binaries. It requires no Redis, message broker, hosted identity provider,
telemetry service, or JavaScript framework. 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 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 Linux development environment. Native Windows is not a release gate or support
promise; downstream users may evaluate the ordinary Go packages elsewhere promise; downstream users may evaluate the ordinary Go packages elsewhere
+3
View File
@@ -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 { func (service *Service) RevokeSession(ctx context.Context, token string) error {
if len(token) < 32 || len(token) > 128 {
return ErrSessionNotFound
}
digest := sha256.Sum256([]byte(token)) digest := sha256.Sum256([]byte(token))
return service.repository.DeleteSession(ctx, digest) return service.repository.DeleteSession(ctx, digest)
} }
+24
View File
@@ -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 } type repositoryStub struct{ sessionErr error }
func (repositoryStub) CreateUser(context.Context, User, string) error { return nil } func (repositoryStub) CreateUser(context.Context, User, string) error { return nil }
+11 -2
View File
@@ -40,7 +40,7 @@ func SetSession(response http.ResponseWriter, config CookieConfig, token string,
if err := config.Validate(); err != nil { if err := config.Validate(); err != nil {
return err return err
} }
if token == "" || len(token) > 128 { if len(token) < 32 || len(token) > 128 {
return errors.New("authhttp: invalid session token") return errors.New("authhttp: invalid session token")
} }
sameSite := config.SameSite sameSite := config.SameSite
@@ -64,16 +64,25 @@ func ClearSession(response http.ResponseWriter, config CookieConfig) error {
} }
func SessionToken(request *http.Request, config CookieConfig) (string, bool) { func SessionToken(request *http.Request, config CookieConfig) (string, bool) {
if config.Validate() != nil {
return "", false
}
cookie, err := request.Cookie(config.Name) 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 "", false
} }
return cookie.Value, true return cookie.Value, true
} }
func Optional(service *auth.Service, config CookieConfig) func(http.Handler) http.Handler { 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 func(next http.Handler) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { 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 token, ok := SessionToken(request, config); ok {
if principal, err := service.Session(request.Context(), token); err == nil { if principal, err := service.Session(request.Context(), token); err == nil {
request = request.WithContext(auth.WithPrincipal(request.Context(), principal)) request = request.WithContext(auth.WithPrincipal(request.Context(), principal))
+38
View File
@@ -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) { func TestCSRFUsesSessionAndPurpose(t *testing.T) {
token := strings.Repeat("s", 43) token := strings.Repeat("s", 43)
csrf, err := CSRFToken(token, "profile:update") 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 } type authHTTPRepository struct{ err error }
func (authHTTPRepository) CreateUser(context.Context, auth.User, string) error { return nil } func (authHTTPRepository) CreateUser(context.Context, auth.User, string) error { return nil }
+5
View File
@@ -12,6 +12,11 @@ and session identifiers, digest-only session storage, Argon2id passwords,
constant-time comparisons, same-origin and CSRF primitives, fail-closed storage constant-time comparisons, same-origin and CSRF primitives, fail-closed storage
errors, and separate safe/sensitive analytics projections. 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 The toolkit does not sandbox application handlers, secure an incorrectly
configured reverse proxy, authorize application routes automatically, encrypt a configured reverse proxy, authorize application routes automatically, encrypt a
compromised host, or decide how long an operator may lawfully retain personal compromised host, or decide how long an operator may lawfully retain personal
+6 -1
View File
@@ -16,6 +16,11 @@ import (
const RecordVersion = 1 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 // Record is deliberately stable and append-log friendly. Sensitive fields are
// populated only when explicitly enabled by Policy. // populated only when explicitly enabled by Policy.
type Record struct { type Record struct {
@@ -38,7 +43,7 @@ type Record struct {
// Validate rejects records that cannot have been produced by this package's // Validate rejects records that cannot have been produced by this package's
// bounded middleware contract. // bounded middleware contract.
func (record Record) Validate() error { 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") return errors.New("requestlog: invalid record")
} }
fields := []struct { fields := []struct {
+14
View File
@@ -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) { func TestJSONLRejectsSymlinkDestination(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
t.Skip("symlink creation is privilege-dependent on Windows") t.Skip("symlink creation is privilege-dependent on Windows")
+1 -1
View File
@@ -191,7 +191,7 @@ func parseForwardedFor(value string) ([]netip.Addr, error) {
result := make([]netip.Addr, 0, len(parts)) result := make([]netip.Addr, 0, len(parts))
for _, part := range parts { for _, part := range parts {
address, err := netip.ParseAddr(strings.TrimSpace(part)) address, err := netip.ParseAddr(strings.TrimSpace(part))
if err != nil { if err != nil || address.Zone() != "" {
return nil, ErrInvalidForwarding return nil, ErrInvalidForwarding
} }
result = append(result, address.Unmap()) result = append(result, address.Unmap())
+10
View File
@@ -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) { 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))}) 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) request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
+12 -3
View File
@@ -76,19 +76,28 @@ func RequireHTTPS(next http.Handler) http.Handler {
// SameOrigin accepts browser requests that are demonstrably same-origin. It // SameOrigin accepts browser requests that are demonstrably same-origin. It
// rejects contradictory fetch metadata even when Origin is absent. // rejects contradictory fetch metadata even when Origin is absent.
func SameOrigin(request *http.Request, allowedOrigin string) bool { 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 return false
} }
origin := strings.TrimSpace(request.Header.Get("Origin")) origin := strings.TrimSpace(request.Header.Get("Origin"))
if origin == "" { if origin == "" {
if site == "same-origin" || site == "none" {
return true return true
} }
switch request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
default:
return false
}
}
want, err := url.Parse(allowedOrigin) 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 return false
} }
got, err := url.Parse(origin) 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. // CSRFToken binds a purpose to opaque session secret material.
+33
View File
@@ -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) { func TestCSRFIsPurposeBound(t *testing.T) {
secret := []byte("0123456789abcdef0123456789abcdef") secret := []byte("0123456789abcdef0123456789abcdef")
token, err := CSRFToken(secret, "account:update") token, err := CSRFToken(secret, "account:update")