From 17bd9453e2f4b7f9ff7aee8f877eafdcacf90885 Mon Sep 17 00:00:00 2001 From: Cole Speelman Date: Thu, 3 Sep 2026 22:30:23 -0400 Subject: [PATCH] Allow explicit local WebAuthn ports --- CHANGELOG.md | 9 +++++++++ README.md | 6 +++--- authwebauthn/service.go | 31 +++++++++++++++++++++++++------ authwebauthn/service_test.go | 14 ++++++++++++++ docs/DOGFOOD.md | 5 +++++ docs/GETTING_STARTED.md | 2 +- docs/MODULES.md | 2 +- docs/PASSKEYS.md | 5 +++++ 8 files changed, 63 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05b2135..f529c68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ # Changelog +## v0.1.0-preview.15 — 2026-09-03 + +- Permit applications to opt into an exact non-default HTTPS WebAuthn origin + port for `localhost` and reserved `.test` relying-party IDs. The configured + origin remains exact, production origins remain portless by default, and + malformed, default, non-canonical, zero, or out-of-range ports fail closed. +- Record the Gamertan local-Caddy dogfood pressure that required this explicit + development boundary without weakening cross-origin ceremony rejection. + ## v0.1.0-preview.14 — 2026-09-03 - Reject header-only, truncated, and structurally invalid PDF uploads in the diff --git a/README.md b/README.md index 46fb971..41c88c6 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and deployment. Adopt one boundary at a time; Go compiles and links only the packages you import. -> **Public preview:** `v0.1.0-preview.13`. APIs may change before a stable +> **Public preview:** `v0.1.0-preview.15`. APIs may change before a stable > release. Linux is the maintained release platform. ## Why Web Foundations? @@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy. Pin the preview in an application module: ```bash -go get gamertan.com/web@v0.1.0-preview.13 +go get gamertan.com/web@v0.1.0-preview.15 go mod verify ``` An application may name the first package it intends to adopt: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.13 +go get gamertan.com/web/requestmeta@v0.1.0-preview.15 ``` The version belongs to the `gamertan.com/web` module. See the diff --git a/authwebauthn/service.go b/authwebauthn/service.go index 26adc7a..39a7205 100644 --- a/authwebauthn/service.go +++ b/authwebauthn/service.go @@ -12,8 +12,10 @@ import ( "errors" "fmt" "io" + "net" "net/url" "regexp" + "strconv" "strings" "time" @@ -36,9 +38,14 @@ const ( var accountNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`) type Config struct { - RPID string - RPDisplayName string - Origin string + RPID string + RPDisplayName string + Origin string + // AllowDevelopmentPort permits an explicit non-default HTTPS port only + // for localhost or a reserved .test relying-party ID. Production origins + // remain portless, while local applications can terminate trusted HTTPS + // without requiring a privileged listener. + AllowDevelopmentPort bool EnrollmentLifetime time.Duration RegistrationTTL time.Duration LoginTTL time.Duration @@ -66,7 +73,7 @@ func New(repository Repository, authService *auth.Service, config Config) (*Serv if repository == nil || authService == nil { return nil, errors.New("authwebauthn: repository and auth service are required") } - if err := validateOrigin(config.RPID, config.Origin); err != nil { + if err := validateOrigin(config.RPID, config.Origin, config.AllowDevelopmentPort); err != nil { return nil, err } if strings.TrimSpace(config.RPDisplayName) == "" || len(config.RPDisplayName) > 80 { @@ -725,12 +732,24 @@ func passwordMigrationBinding(userID string) [32]byte { return BindingDigest([]byte("gamertan-web/password-to-passkey/v1\x00" + userID)) } -func validateOrigin(rpID, rawOrigin string) error { +func validateOrigin(rpID, rawOrigin string, allowDevelopmentPort bool) error { if strings.TrimSpace(rpID) == "" || strings.TrimSpace(rawOrigin) == "" { return errors.New("authwebauthn: relying-party ID and origin are required") } origin, err := url.Parse(rawOrigin) - if err != nil || origin.Scheme != "https" || origin.Hostname() != rpID || origin.Port() != "" || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" { + if err != nil || origin.Scheme != "https" || origin.Hostname() != rpID || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" { + return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin") + } + port := origin.Port() + if port == "" { + if origin.Host != rpID { + return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin") + } + return nil + } + developmentRP := rpID == "localhost" || strings.HasSuffix(rpID, ".test") + value, portErr := strconv.ParseUint(port, 10, 16) + if !allowDevelopmentPort || !developmentRP || portErr != nil || value == 0 || value == 443 || strconv.FormatUint(value, 10) != port || origin.Host != net.JoinHostPort(rpID, port) { return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin") } return nil diff --git a/authwebauthn/service_test.go b/authwebauthn/service_test.go index ce7f8a3..1a7b867 100644 --- a/authwebauthn/service_test.go +++ b/authwebauthn/service_test.go @@ -231,11 +231,25 @@ func TestConfigurationAndEntropyFailures(t *testing.T) { {RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "http://tend.gamertan.com"}, {RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://other.gamertan.com"}, {RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com/path"}, + {RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:8443"}, + {RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com:8443", AllowDevelopmentPort: true}, + {RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:443", AllowDevelopmentPort: true}, + {RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:08443", AllowDevelopmentPort: true}, + {RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:0", AllowDevelopmentPort: true}, } { if _, err = authwebauthn.New(store, authService, config); err == nil { t.Fatalf("accepted config=%+v", config) } } + for _, config := range []authwebauthn.Config{ + {RPID: "localhost", RPDisplayName: "Tend Local", Origin: "https://localhost:8443", AllowDevelopmentPort: true}, + {RPID: "tend.test", RPDisplayName: "Tend Local", Origin: "https://tend.test:8443", AllowDevelopmentPort: true}, + } { + configured, configureErr := authwebauthn.New(store, authService, config) + if configureErr != nil || configured == nil { + t.Fatalf("development config=%+v service=%v err=%v", config, configured, configureErr) + } + } service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com", Random: failingReader{}}) if err != nil { t.Fatal(err) diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md index 35ecc39..7169a2c 100644 --- a/docs/DOGFOOD.md +++ b/docs/DOGFOOD.md @@ -49,3 +49,8 @@ application concern belongs in the shared module. passkey and replacement code digests. `authrecovery.BeginPasskey` and `FinishPasskey` now provide that boundary without creating an authenticated session; Gamertan keeps the raw grant only in a short-lived HttpOnly cookie. +- A portless-only WebAuthn origin rule made an unprivileged local HTTPS + exercise impossible even though WebAuthn origins include ports. The passkey + service now permits an explicit development port only when applications opt + in and the RP ID is `localhost` or reserved `.test`; production origins keep + the original portless default. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index cf506e6..9d72c1a 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its module checksum: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.13 +go get gamertan.com/web/requestmeta@v0.1.0-preview.15 go mod verify ``` diff --git a/docs/MODULES.md b/docs/MODULES.md index f47887e..a001e06 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta" and request the containing module at an exact version: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.13 +go get gamertan.com/web/requestmeta@v0.1.0-preview.15 ``` Only imported packages are compiled and linked. The packages nevertheless diff --git a/docs/PASSKEYS.md b/docs/PASSKEYS.md index 440168c..3ab8e4e 100644 --- a/docs/PASSKEYS.md +++ b/docs/PASSKEYS.md @@ -9,6 +9,11 @@ authorization decisions, session cookie, HTML, and local recovery command. ## Fixed security policy - Use an exact HTTPS origin whose hostname equals the relying-party ID. +- Keep production origins portless. For local development only, + `AllowDevelopmentPort` permits one explicit non-default port when the RP ID + is exactly `localhost` or beneath the reserved `.test` top-level domain. The + configured origin, browser `Origin`, and WebAuthn verifier origin must still + match exactly. - Reject cross-origin ceremonies. - Require discoverable credentials and user verification. - Request no attestation conveyance.