security: harden first preview boundaries
verify / verify (push) Successful in 3m1s

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 a18f1dd22a
13 changed files with 181 additions and 12 deletions
+7 -2
View File
@@ -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.
+16 -2
View File
@@ -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
+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 {
if len(token) < 32 || len(token) > 128 {
return ErrSessionNotFound
}
digest := sha256.Sum256([]byte(token))
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 }
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 {
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))
+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) {
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 }
+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
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
+6 -1
View File
@@ -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 {
+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) {
if runtime.GOOS == "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))
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())
+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) {
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)
+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
// 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 == "" {
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.
+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) {
secret := []byte("0123456789abcdef0123456789abcdef")
token, err := CSRFToken(secret, "account:update")