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
|
# 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+13
-4
@@ -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 == "" {
|
||||||
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)
|
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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user