Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17bd9453e2 | ||
|
|
d8b09c8ae5 |
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
# Changelog
|
# 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
|
||||||
|
bounded media preparer. Accepted attachments now require a supported PDF
|
||||||
|
version, terminal EOF marker, numeric in-range `startxref`, and either a
|
||||||
|
traditional xref/trailer or xref-stream object at the declared offset.
|
||||||
|
- Keep PDF handling storage-neutral and non-rendering: applications still own
|
||||||
|
authorization, reference tracking, attachment disposition, and lifecycle.
|
||||||
|
|
||||||
## v0.1.0-preview.13 — 2026-09-03
|
## v0.1.0-preview.13 — 2026-09-03
|
||||||
|
|
||||||
- Complete the password-plus-recovery-code flow with a short-lived restricted
|
- Complete the password-plus-recovery-code flow with a short-lived restricted
|
||||||
|
|||||||
@@ -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
|
deployment. Adopt one boundary at a time; Go compiles and links only the
|
||||||
packages you import.
|
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.
|
> release. Linux is the maintained release platform.
|
||||||
|
|
||||||
## Why Web Foundations?
|
## Why Web Foundations?
|
||||||
@@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy.
|
|||||||
Pin the preview in an application module:
|
Pin the preview in an application module:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web@v0.1.0-preview.13
|
go get gamertan.com/web@v0.1.0-preview.15
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
An application may name the first package it intends to adopt:
|
An application may name the first package it intends to adopt:
|
||||||
|
|
||||||
```bash
|
```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
|
The version belongs to the `gamertan.com/web` module. See the
|
||||||
|
|||||||
+22
-3
@@ -12,8 +12,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -39,6 +41,11 @@ type Config struct {
|
|||||||
RPID string
|
RPID string
|
||||||
RPDisplayName string
|
RPDisplayName string
|
||||||
Origin 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
|
EnrollmentLifetime time.Duration
|
||||||
RegistrationTTL time.Duration
|
RegistrationTTL time.Duration
|
||||||
LoginTTL time.Duration
|
LoginTTL time.Duration
|
||||||
@@ -66,7 +73,7 @@ func New(repository Repository, authService *auth.Service, config Config) (*Serv
|
|||||||
if repository == nil || authService == nil {
|
if repository == nil || authService == nil {
|
||||||
return nil, errors.New("authwebauthn: repository and auth service are required")
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(config.RPDisplayName) == "" || len(config.RPDisplayName) > 80 {
|
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))
|
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) == "" {
|
if strings.TrimSpace(rpID) == "" || strings.TrimSpace(rawOrigin) == "" {
|
||||||
return errors.New("authwebauthn: relying-party ID and origin are required")
|
return errors.New("authwebauthn: relying-party ID and origin are required")
|
||||||
}
|
}
|
||||||
origin, err := url.Parse(rawOrigin)
|
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 errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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: "http://tend.gamertan.com"},
|
||||||
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://other.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: "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 {
|
if _, err = authwebauthn.New(store, authService, config); err == nil {
|
||||||
t.Fatalf("accepted config=%+v", config)
|
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{}})
|
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com", Random: failingReader{}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -49,3 +49,8 @@ application concern belongs in the shared module.
|
|||||||
passkey and replacement code digests. `authrecovery.BeginPasskey` and
|
passkey and replacement code digests. `authrecovery.BeginPasskey` and
|
||||||
`FinishPasskey` now provide that boundary without creating an authenticated
|
`FinishPasskey` now provide that boundary without creating an authenticated
|
||||||
session; Gamertan keeps the raw grant only in a short-lived HttpOnly cookie.
|
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.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
|
|||||||
module checksum:
|
module checksum:
|
||||||
|
|
||||||
```bash
|
```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
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
|
|||||||
and request the containing module at an exact version:
|
and request the containing module at an exact version:
|
||||||
|
|
||||||
```bash
|
```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
|
Only imported packages are compiled and linked. The packages nevertheless
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ authorization decisions, session cookie, HTML, and local recovery command.
|
|||||||
## Fixed security policy
|
## Fixed security policy
|
||||||
|
|
||||||
- Use an exact HTTPS origin whose hostname equals the relying-party ID.
|
- 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.
|
- Reject cross-origin ceremonies.
|
||||||
- Require discoverable credentials and user verification.
|
- Require discoverable credentials and user verification.
|
||||||
- Request no attestation conveyance.
|
- Request no attestation conveyance.
|
||||||
|
|||||||
+57
-1
@@ -20,6 +20,7 @@ import (
|
|||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -121,7 +122,7 @@ func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
detected := http.DetectContentType(data)
|
detected := http.DetectContentType(data)
|
||||||
if detected == "application/pdf" && bytes.HasPrefix(data, []byte("%PDF-")) {
|
if detected == "application/pdf" && validPDF(data) {
|
||||||
result := Prepared{Data: append([]byte(nil), data...), MediaType: "application/pdf", Kind: KindAttachment, OriginalName: name}
|
result := Prepared{Data: append([]byte(nil), data...), MediaType: "application/pdf", Kind: KindAttachment, OriginalName: name}
|
||||||
result.Digest = sha256.Sum256(result.Data)
|
result.Digest = sha256.Sum256(result.Data)
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -156,6 +157,61 @@ func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, er
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validPDF performs a deliberately bounded structural check without trying to
|
||||||
|
// render or interpret document content. It rejects header-only spoofing and
|
||||||
|
// truncated uploads by requiring a supported header, terminal EOF marker, a
|
||||||
|
// numeric startxref offset, and either a traditional xref table with trailer
|
||||||
|
// or an xref-stream object at that offset.
|
||||||
|
func validPDF(data []byte) bool {
|
||||||
|
if len(data) < 32 || !bytes.HasPrefix(data, []byte("%PDF-")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
headerEnd := bytes.IndexAny(data, "\r\n")
|
||||||
|
if headerEnd < 8 || headerEnd > 32 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
header := string(bytes.TrimSpace(data[:headerEnd]))
|
||||||
|
if header != "%PDF-1.0" && header != "%PDF-1.1" && header != "%PDF-1.2" && header != "%PDF-1.3" && header != "%PDF-1.4" && header != "%PDF-1.5" && header != "%PDF-1.6" && header != "%PDF-1.7" && header != "%PDF-2.0" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
trimmed := bytes.TrimRight(data, "\x00\t\n\f\r ")
|
||||||
|
if !bytes.HasSuffix(trimmed, []byte("%%EOF")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
eof := len(trimmed) - len("%%EOF")
|
||||||
|
start := bytes.LastIndex(trimmed[:eof], []byte("startxref"))
|
||||||
|
if start < headerEnd {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cursor := start + len("startxref")
|
||||||
|
for cursor < eof && (trimmed[cursor] == ' ' || trimmed[cursor] == '\t' || trimmed[cursor] == '\r' || trimmed[cursor] == '\n' || trimmed[cursor] == '\f') {
|
||||||
|
cursor++
|
||||||
|
}
|
||||||
|
digits := cursor
|
||||||
|
for cursor < eof && trimmed[cursor] >= '0' && trimmed[cursor] <= '9' && cursor-digits < 20 {
|
||||||
|
cursor++
|
||||||
|
}
|
||||||
|
if cursor == digits {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(bytes.TrimSpace(trimmed[cursor:eof])) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
offset, err := strconv.ParseInt(string(trimmed[digits:cursor]), 10, 64)
|
||||||
|
if err != nil || offset < int64(headerEnd+1) || offset >= int64(start) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
target := trimmed[int(offset):start]
|
||||||
|
if bytes.HasPrefix(target, []byte("xref")) {
|
||||||
|
return bytes.Contains(target, []byte("trailer"))
|
||||||
|
}
|
||||||
|
lineEnd := bytes.IndexByte(target, '\n')
|
||||||
|
if lineEnd < 5 || lineEnd > 80 || !bytes.Contains(target[:lineEnd], []byte(" obj")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bytes.Contains(target, []byte("/Type /XRef")) || bytes.Contains(target, []byte("/Type/XRef"))
|
||||||
|
}
|
||||||
|
|
||||||
func Extension(mediaType string) string {
|
func Extension(mediaType string) string {
|
||||||
switch mediaType {
|
switch mediaType {
|
||||||
case "image/jpeg":
|
case "image/jpeg":
|
||||||
|
|||||||
+21
-1
@@ -5,6 +5,7 @@ package media
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
@@ -33,7 +34,8 @@ func TestPrepareReencodesRasterAndStripsTrailingData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPreparePDFIsAttachment(t *testing.T) {
|
func TestPreparePDFIsAttachment(t *testing.T) {
|
||||||
prepared, err := Prepare(strings.NewReader("%PDF-1.7\nsmall fixture"), "guide.pdf", Limits{})
|
pdf := minimalPDF()
|
||||||
|
prepared, err := Prepare(bytes.NewReader(pdf), "guide.pdf", Limits{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -42,6 +44,18 @@ func TestPreparePDFIsAttachment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrepareRejectsMalformedPDF(t *testing.T) {
|
||||||
|
for _, source := range []string{
|
||||||
|
"%PDF-1.7\nsmall fixture",
|
||||||
|
"%PDF-9.9\nxref\ntrailer\nstartxref\n9\n%%EOF",
|
||||||
|
"%PDF-1.7\nxref\ntrailer\nstartxref\n999999\n%%EOF",
|
||||||
|
} {
|
||||||
|
if _, err := Prepare(strings.NewReader(source), "broken.pdf", Limits{}); !errors.Is(err, ErrInvalidMedia) {
|
||||||
|
t.Fatalf("malformed PDF error=%v source=%q", err, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
|
func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
|
||||||
if _, err := Prepare(strings.NewReader("<svg><script/></svg>"), "bad.svg", Limits{}); !errors.Is(err, ErrInvalidMedia) {
|
if _, err := Prepare(strings.NewReader("<svg><script/></svg>"), "bad.svg", Limits{}); !errors.Is(err, ErrInvalidMedia) {
|
||||||
t.Fatalf("svg err=%v", err)
|
t.Fatalf("svg err=%v", err)
|
||||||
@@ -50,3 +64,9 @@ func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
|
|||||||
t.Fatalf("large err=%v", err)
|
t.Fatalf("large err=%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func minimalPDF() []byte {
|
||||||
|
prefix := []byte("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")
|
||||||
|
offset := len(prefix)
|
||||||
|
return append(prefix, []byte(fmt.Sprintf("xref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", offset))...)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package medialocal
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -20,7 +21,9 @@ func TestStoreRoundTripAndIdempotentPut(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
prepared, err := media.Prepare(bytes.NewReader([]byte("%PDF-1.7\nfixture")), "fixture.pdf", media.Limits{})
|
prefix := []byte("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")
|
||||||
|
pdf := append(prefix, []byte(fmt.Sprintf("xref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(prefix)))...)
|
||||||
|
prepared, err := media.Prepare(bytes.NewReader(pdf), "fixture.pdf", media.Limits{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user