Export the reviewed application-neutral package set through the exact public allowlist. Development history and private application evidence remain outside this canonical source root. Developed with material AI assistance under maintainer review. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package websec
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Limiter is a bounded in-memory token bucket intended for one process. A
|
||||
// distributed application should provide a different limiter at its boundary.
|
||||
type Limiter struct {
|
||||
mu sync.Mutex
|
||||
rate float64
|
||||
burst float64
|
||||
maxEntries int
|
||||
entries map[string]bucket
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
updated time.Time
|
||||
}
|
||||
|
||||
type LimitConfig struct {
|
||||
RatePerSecond float64
|
||||
Burst int
|
||||
MaxEntries int
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func NewLimiter(config LimitConfig) (*Limiter, error) {
|
||||
if config.RatePerSecond <= 0 || config.RatePerSecond > 100000 || config.Burst < 1 || config.Burst > 100000 || config.MaxEntries < 1 || config.MaxEntries > 1000000 {
|
||||
return nil, ErrInvalidLimit
|
||||
}
|
||||
if config.Now == nil {
|
||||
config.Now = time.Now
|
||||
}
|
||||
return &Limiter{rate: config.RatePerSecond, burst: float64(config.Burst), maxEntries: config.MaxEntries, entries: make(map[string]bucket), now: config.Now}, nil
|
||||
}
|
||||
|
||||
var ErrInvalidLimit = limitError("websec: invalid rate-limit configuration")
|
||||
|
||||
type limitError string
|
||||
|
||||
func (err limitError) Error() string { return string(err) }
|
||||
|
||||
func (limiter *Limiter) Allow(key string) bool {
|
||||
if key == "" || len(key) > 512 {
|
||||
return false
|
||||
}
|
||||
now := limiter.now()
|
||||
limiter.mu.Lock()
|
||||
defer limiter.mu.Unlock()
|
||||
current, exists := limiter.entries[key]
|
||||
if !exists {
|
||||
if len(limiter.entries) >= limiter.maxEntries {
|
||||
limiter.evictOldest()
|
||||
}
|
||||
current = bucket{tokens: limiter.burst, updated: now}
|
||||
}
|
||||
elapsed := now.Sub(current.updated).Seconds()
|
||||
if elapsed > 0 {
|
||||
current.tokens = min(limiter.burst, current.tokens+elapsed*limiter.rate)
|
||||
current.updated = now
|
||||
}
|
||||
if current.tokens < 1 {
|
||||
limiter.entries[key] = current
|
||||
return false
|
||||
}
|
||||
current.tokens--
|
||||
limiter.entries[key] = current
|
||||
return true
|
||||
}
|
||||
|
||||
func (limiter *Limiter) evictOldest() {
|
||||
var oldestKey string
|
||||
var oldest time.Time
|
||||
for key, value := range limiter.entries {
|
||||
if oldestKey == "" || value.updated.Before(oldest) {
|
||||
oldestKey, oldest = key, value.updated
|
||||
}
|
||||
}
|
||||
delete(limiter.entries, oldestKey)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package websec supplies small browser and HTTP security primitives without
|
||||
// taking ownership of application routes or authorization policy.
|
||||
package websec
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gamertan.com/web/requestmeta"
|
||||
)
|
||||
|
||||
type HeaderPolicy struct {
|
||||
ContentSecurityPolicy string
|
||||
ReferrerPolicy string
|
||||
FrameOptions string
|
||||
PermissionsPolicy string
|
||||
HSTS string
|
||||
}
|
||||
|
||||
func Headers(policy func(*http.Request) HeaderPolicy) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
selected := HeaderPolicy{}
|
||||
if policy != nil {
|
||||
selected = policy(request)
|
||||
}
|
||||
header := response.Header()
|
||||
header.Set("X-Content-Type-Options", "nosniff")
|
||||
if selected.ContentSecurityPolicy != "" {
|
||||
header.Set("Content-Security-Policy", selected.ContentSecurityPolicy)
|
||||
}
|
||||
if selected.ReferrerPolicy != "" {
|
||||
header.Set("Referrer-Policy", selected.ReferrerPolicy)
|
||||
}
|
||||
if selected.FrameOptions != "" {
|
||||
header.Set("X-Frame-Options", selected.FrameOptions)
|
||||
}
|
||||
if selected.PermissionsPolicy != "" {
|
||||
header.Set("Permissions-Policy", selected.PermissionsPolicy)
|
||||
}
|
||||
if selected.HSTS != "" {
|
||||
header.Set("Strict-Transport-Security", selected.HSTS)
|
||||
}
|
||||
next.ServeHTTP(response, request)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func IsHTTPS(request *http.Request) bool {
|
||||
if metadata, ok := requestmeta.FromContext(request.Context()); ok {
|
||||
return metadata.Scheme == "https"
|
||||
}
|
||||
return request.TLS != nil
|
||||
}
|
||||
|
||||
func RequireHTTPS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if !IsHTTPS(request) {
|
||||
response.Header().Set("Cache-Control", "no-store")
|
||||
http.Error(response, "HTTPS required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(response, request)
|
||||
})
|
||||
}
|
||||
|
||||
// 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" {
|
||||
return false
|
||||
}
|
||||
origin := strings.TrimSpace(request.Header.Get("Origin"))
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
want, err := url.Parse(allowedOrigin)
|
||||
if err != nil || want.Scheme == "" || want.Host == "" || want.Path != "" {
|
||||
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 == ""
|
||||
}
|
||||
|
||||
// CSRFToken binds a purpose to opaque session secret material.
|
||||
func CSRFToken(sessionSecret []byte, purpose string) (string, error) {
|
||||
if len(sessionSecret) < 16 || purpose == "" || len(purpose) > 128 || strings.ContainsAny(purpose, "\x00\r\n") {
|
||||
return "", errors.New("websec: invalid CSRF input")
|
||||
}
|
||||
mac := hmac.New(sha256.New, sessionSecret)
|
||||
_, _ = io.WriteString(mac, purpose)
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func VerifyCSRF(sessionSecret []byte, purpose, candidate string) bool {
|
||||
want, err := CSRFToken(sessionSecret, purpose)
|
||||
if err != nil || len(candidate) != len(want) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(candidate), []byte(want)) == 1
|
||||
}
|
||||
|
||||
func SafeLocalRedirect(value, fallback string) string {
|
||||
if !safePath(fallback) {
|
||||
fallback = "/"
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.IsAbs() || parsed.Host != "" || !safePath(parsed.Path) || strings.HasPrefix(value, "//") || parsed.User != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed.RequestURI()
|
||||
}
|
||||
|
||||
func safePath(value string) bool {
|
||||
return strings.HasPrefix(value, "/") && !strings.HasPrefix(value, "//") && !strings.ContainsAny(value, "\x00\r\n\\")
|
||||
}
|
||||
|
||||
func LimitBody(response http.ResponseWriter, request *http.Request, bytes int64) error {
|
||||
if bytes <= 0 {
|
||||
return errors.New("websec: body limit must be positive")
|
||||
}
|
||||
request.Body = http.MaxBytesReader(response, request.Body, bytes)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package websec
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSameOriginRejectsCrossSiteAndContradiction(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "https://example.test/change", nil)
|
||||
request.Header.Set("Origin", "https://example.test")
|
||||
request.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
if !SameOrigin(request, "https://example.test") {
|
||||
t.Fatal("same origin rejected")
|
||||
}
|
||||
request.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
if SameOrigin(request, "https://example.test") {
|
||||
t.Fatal("cross-site accepted")
|
||||
}
|
||||
request.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
request.Header.Set("Origin", "https://attacker.test")
|
||||
if SameOrigin(request, "https://example.test") {
|
||||
t.Fatal("foreign origin accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFIsPurposeBound(t *testing.T) {
|
||||
secret := []byte("0123456789abcdef0123456789abcdef")
|
||||
token, err := CSRFToken(secret, "account:update")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !VerifyCSRF(secret, "account:update", token) {
|
||||
t.Fatal("valid token rejected")
|
||||
}
|
||||
if VerifyCSRF(secret, "account:delete", token) {
|
||||
t.Fatal("cross-purpose token accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeLocalRedirect(t *testing.T) {
|
||||
for _, unsafe := range []string{"https://attacker.test/", "//attacker.test/", "/\\attacker", "javascript:alert(1)"} {
|
||||
if got := SafeLocalRedirect(unsafe, "/home"); got != "/home" {
|
||||
t.Fatalf("%q => %q", unsafe, got)
|
||||
}
|
||||
}
|
||||
if got := SafeLocalRedirect("/items?page=2", "/"); got != "/items?page=2" {
|
||||
t.Fatalf("got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterRefillsAndStaysBounded(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
limiter, err := NewLimiter(LimitConfig{RatePerSecond: 1, Burst: 2, MaxEntries: 2, Now: func() time.Time { return now }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !limiter.Allow("a") || !limiter.Allow("a") || limiter.Allow("a") {
|
||||
t.Fatal("unexpected initial budget")
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
if !limiter.Allow("a") {
|
||||
t.Fatal("token did not refill")
|
||||
}
|
||||
_ = limiter.Allow("b")
|
||||
_ = limiter.Allow("c")
|
||||
if len(limiter.entries) != 2 {
|
||||
t.Fatalf("entries=%d", len(limiter.entries))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user