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:
+7
-2
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user