mirror of
https://github.com/lukaszraczylo/gohoarder.git
synced 2026-07-15 05:25:45 +00:00
feat: comprehensive audit + Tier 3 wiring (security/correctness/features)
Multi-agent audit + fix pass covering bugs, security, dead config wiring,
and missing features. All quality gates green: build/vet/test -race/govulncheck/
golangci-lint. Frontend tests 47/47.
SECURITY & CORRECTNESS
- Scanner pipeline fail-closed: cache.go scan timeout now 503 (was goto servePkg);
scanner.go all-scanners-fail saves ScanStatusError; CheckVulnerabilities blocks
on missing/error result instead of allowing.
- Scanner argv corrections: govulncheck source-mode + go.mod discovery;
npm-audit generates lockfile via npm install --package-lock-only --ignore-scripts;
pip-audit per-extension dispatch (wheel direct / sdist extract+pyproject).
- GHSA version-range filtering implemented (was always-include); url.QueryEscape
on package name.
- pypi SSRF closed via host allowlist on original_url; url.QueryEscape on
rewriteURL output.
- Path traversal: cache temp file uses os.CreateTemp; smb keyToPath sanitized;
filesystem keyToPath returns error + filepath.Abs prefix-verify.
- Goroutine leaks plugged: cache.cleanupWorker stop channel; auth.ValidationCache
Stop() with sync.Once; ws.unregister buffered with non-blocking send.
- WebSocket double-close panic fixed via sync.Once Client.closeSend().
- Race conditions: gormstore.registryCache sync.RWMutex (was concurrent map
panic); auth.LastUsedAt no longer mutated under RLock; analytics strict
lock-ordering invariant (statsMu before downloadsMu).
- Auth validator fails CLOSED on transport/unknown-status errors (was returning
true,err 'allow cache fallback').
- Credential cache hash full SHA256 (was 8-byte truncate; eliminates 2^32
collision risk).
- filesystem.fs.used incremented after successful rename (no quota inflation
race).
- gormstore aggregation events deleted in same tx as insert (no double-counting).
- gormstore.SavePackage uses Updates(map) so zero-value security flags
(RequiresAuth=false) can be cleared.
- partition_manager validates partition name regex before DROP TABLE.
- vcs/git: version regex validation rejects --option-injection; checkout uses
--detach -- separator; removed TrimPrefix(repo,'v') that corrupted vault/
vitess/vim-go.
- WebSocket CheckOrigin allowlist (was return true; CSWSH closed); same-origin
default + ServerConfig.AllowedOrigins.
- fiber CVE GO-2026-4543 patched (v2.52.10 -> v2.52.12).
FUNCTIONAL FEATURES
- API key DB persistence: APIKeyModel + migration 202604280002, full CRUD,
async LastUsedAt updates with WaitGroup-tracked goroutines drained on Close.
- Admin bootstrap via GOHOARDER_BOOTSTRAP_ADMIN_KEY env var; idempotent
(skipped when non-revoked admin already exists).
- Auth middleware factory in pkg/app: RequireAuth, RequireRole, RequirePermission,
OptionalAuth. Mounted on proxy + read APIs when Auth.Enabled=true.
DELETE /api/packages/* requires admin role. /health public for k8s probes.
- NFS storage backend: pkg/storage/nfs wraps filesystem with /proc/mounts
detection (Linux), post-write fsync, read-after-write health probe.
- WebSocket real-time pipeline: pkg/events Broadcaster interface; cache + scanner
managers emit EventPackageCached / EventPackageDownloaded / EventScanComplete;
app.go runs 30s EventStatsUpdate ticker. Frontend has full WS client (reconnect
with exponential backoff 1s->30s, 25s heartbeat, subscriber pattern), Pinia
realtime store, Dashboard live indicator + activity feed + reactive stats
override.
- TLS termination: fiber.ListenTLS when Server.TLS.Enabled.
- Pre-warming: Prewarming.Enabled flag wired (was hardcoded false); Interval,
MaxConcurrent, TopPackages plumbed.
- NetworkConfig wired (timeouts, retry, rate-limit, circuit-breaker).
- AuthConfig.BcryptCost wired.
- Security.Scanners.Static.MaxPackageSize plumbed to cache.io.LimitReader.
- Handlers.{Go,NPM,PyPI}.Enabled gates route mounting.
- Graceful shutdown: authManager.Close drains async writes.
LINT/HYGIENE
- 80 golangci-lint issues resolved: errcheck (Close/Write/RemoveAll wrapped),
gofmt, gosec G104/G306, govet shadow renames + fieldalignment reorders,
staticcheck ST1000 pkg comments + QF1003 tagged switches + QF1008 embedded
field selectors + QF1001 De Morgan's law.
BEHAVIOR CHANGES
- Scanner now fail-closed: deployments without scanner binaries
(trivy/govulncheck/npm-audit/pip-audit/grype) BLOCK packages instead of
serving unscanned. Add binaries or plan a future allow-on-scan-error flag.
- WS origin defaults to same-origin; cross-origin dashboards must set
server.allowed_origins.
- Validator no longer fails open; private packages won't serve from cache when
validation can't be performed.
- download_events retention dropped from 24h to ~5min (deleted in agg tx).
Migration 202604280001 purges pre-upgrade events.
- DELETE /api/packages/* requires admin role when auth enabled.
NEW PACKAGES
- pkg/events - Broadcaster interface
- pkg/storage/nfs - NFS-aware filesystem wrapper
DEFERRED (out of scope this pass)
- Static scanner implementation (config defines AllowedLicenses/MaxPackageSize/
BlockSuspicious but pkg/scanner/static/ does not exist).
- AuthConfig.AuditLog wiring (no audit log infra yet).
- CacheConfig.TTLOverrides passthrough (would touch cache.Config beyond
integrator scope).
This commit is contained in:
+360
-63
@@ -1,11 +1,15 @@
|
||||
// Package app wires the GoHoarder HTTP application together: config,
|
||||
// storage, metadata, scanners, and route handlers.
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -29,6 +33,7 @@ import (
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/scanner"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/storage"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/storage/filesystem"
|
||||
nfsstore "github.com/lukaszraczylo/gohoarder/pkg/storage/nfs"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/storage/s3"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/storage/smb"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/vcs"
|
||||
@@ -54,23 +59,24 @@ type App struct {
|
||||
cdnMiddleware *cdn.Middleware
|
||||
}
|
||||
|
||||
// New creates a new application instance
|
||||
// New creates a new application instance.
|
||||
// The local receiver is named "a" to avoid shadowing the package name "app".
|
||||
func New(cfg *config.Config) (*App, error) {
|
||||
app := &App{
|
||||
a := &App{
|
||||
config: cfg,
|
||||
}
|
||||
|
||||
// Initialize components
|
||||
if err := app.initializeComponents(); err != nil {
|
||||
if err := a.initializeComponents(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Setup HTTP server and routes
|
||||
if err := app.setupServer(); err != nil {
|
||||
if err := a.setupServer(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return app, nil
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// initializeComponents initializes all application components
|
||||
@@ -105,6 +111,18 @@ func (a *App) initializeComponents() error {
|
||||
MaxSizeBytes: a.config.Cache.MaxSizeBytes,
|
||||
PoolSize: 5, // Default connection pool size
|
||||
})
|
||||
case "nfs":
|
||||
// SyncWrites defaults to true (durable). Pointer-bool lets operators
|
||||
// opt out via explicit `sync_writes: false` in config.
|
||||
syncWrites := true
|
||||
if a.config.Storage.NFS.SyncWrites != nil {
|
||||
syncWrites = *a.config.Storage.NFS.SyncWrites
|
||||
}
|
||||
a.storage, err = nfsstore.New(nfsstore.Config{
|
||||
Path: a.config.Storage.Path,
|
||||
MaxSize: a.config.Cache.MaxSizeBytes,
|
||||
SyncWrites: syncWrites,
|
||||
}, log.Logger)
|
||||
default:
|
||||
log.Warn().
|
||||
Str("backend", a.config.Storage.Backend).
|
||||
@@ -200,36 +218,88 @@ func (a *App) initializeComponents() error {
|
||||
FlushInterval: 5 * time.Minute,
|
||||
})
|
||||
|
||||
// Initialize cache manager with scanner and analytics
|
||||
// Initialize cache manager with scanner and analytics. MaxPackageSize is
|
||||
// taken from Security.Scanners.Static (the static analyser already exposes
|
||||
// this knob and it doubles as a cache hard cap).
|
||||
log.Info().Msg("Initializing cache manager")
|
||||
cleanupInterval := a.config.Cache.CleanupInterval
|
||||
if cleanupInterval == 0 {
|
||||
cleanupInterval = 5 * time.Minute
|
||||
}
|
||||
a.cache, err = cache.New(a.storage, a.metadata, a.scanManager, a.analyticsEngine, cache.Config{
|
||||
DefaultTTL: a.config.Cache.DefaultTTL,
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
CleanupInterval: cleanupInterval,
|
||||
MaxPackageSize: a.config.Security.Scanners.Static.MaxPackageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize cache: %w", err)
|
||||
}
|
||||
|
||||
// Initialize network client
|
||||
// Initialize network client. Pull values from cfg.Network where set;
|
||||
// fall back to existing literals so unset fields preserve current
|
||||
// behaviour. cfg.Network.* uses different semantic names (per-IP rate,
|
||||
// retry struct, etc.) so we map explicitly rather than blindly assign.
|
||||
log.Info().Msg("Initializing network client")
|
||||
netTimeout := a.config.Network.ReadTimeout
|
||||
if netTimeout == 0 {
|
||||
netTimeout = 5 * time.Minute
|
||||
}
|
||||
maxRetries := a.config.Network.Retry.MaxAttempts
|
||||
if maxRetries == 0 {
|
||||
maxRetries = 3
|
||||
}
|
||||
retryDelay := a.config.Network.Retry.InitialBackoff
|
||||
if retryDelay == 0 {
|
||||
retryDelay = 1 * time.Second
|
||||
}
|
||||
rateLimit := float64(a.config.Network.RateLimit.PerIP)
|
||||
if rateLimit == 0 {
|
||||
rateLimit = 100
|
||||
}
|
||||
rateBurst := a.config.Network.RateLimit.BurstSize
|
||||
if rateBurst == 0 {
|
||||
rateBurst = 10
|
||||
}
|
||||
cbThreshold := a.config.Network.CircuitBreaker.Threshold
|
||||
if cbThreshold == 0 {
|
||||
cbThreshold = 5
|
||||
}
|
||||
cbTimeout := a.config.Network.CircuitBreaker.Timeout
|
||||
if cbTimeout == 0 {
|
||||
cbTimeout = 30 * time.Second
|
||||
}
|
||||
a.networkClient = network.NewClient(network.Config{
|
||||
Timeout: 5 * time.Minute,
|
||||
MaxRetries: 3,
|
||||
RetryDelay: 1 * time.Second,
|
||||
RateLimit: 100,
|
||||
RateBurst: 10,
|
||||
Timeout: netTimeout,
|
||||
MaxRetries: maxRetries,
|
||||
RetryDelay: retryDelay,
|
||||
RateLimit: rateLimit,
|
||||
RateBurst: rateBurst,
|
||||
CircuitBreaker: network.CircuitBreakerConfig{
|
||||
Enabled: true,
|
||||
FailureThreshold: 5,
|
||||
FailureThreshold: cbThreshold,
|
||||
SuccessThreshold: 2,
|
||||
Timeout: 30 * time.Second,
|
||||
Timeout: cbTimeout,
|
||||
},
|
||||
UserAgent: "GoHoarder/1.0",
|
||||
})
|
||||
|
||||
// Initialize authentication manager
|
||||
// Initialize authentication manager. NewWithStore persists keys via the
|
||||
// metadata layer; Load hydrates the in-memory cache from prior state.
|
||||
// Bootstrap creates an admin from env on first boot when no admin exists.
|
||||
log.Info().Msg("Initializing authentication manager")
|
||||
a.authManager = auth.New()
|
||||
authCfg := auth.Config{BcryptCost: a.config.Auth.BcryptCost}
|
||||
a.authManager = auth.NewWithStore(authCfg, a.metadata)
|
||||
loadCtx, loadCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if err := a.authManager.Load(loadCtx); err != nil {
|
||||
// Empty key set is recoverable: log and continue rather than fail boot.
|
||||
log.Warn().Err(err).Msg("Failed to load API keys from metadata store; starting with empty key set")
|
||||
}
|
||||
loadCancel()
|
||||
bootCtx, bootCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if err := auth.BootstrapAdminFromEnv(bootCtx, a.metadata, authCfg); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to bootstrap admin API key from environment")
|
||||
}
|
||||
bootCancel()
|
||||
|
||||
// Initialize rescan worker if enabled
|
||||
if a.config.Security.Enabled && a.config.Security.RescanInterval > 0 {
|
||||
@@ -242,17 +312,23 @@ func (a *App) initializeComponents() error {
|
||||
a.wsServer = websocket.NewServer(websocket.Config{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(_ *http.Request) bool {
|
||||
return true // Allow all origins in development
|
||||
},
|
||||
CheckOrigin: buildCheckOrigin(a.config.Server.AllowedOrigins),
|
||||
})
|
||||
|
||||
// Initialize pre-warming worker
|
||||
// Wire WebSocket server as the broadcaster for cache + scanner so
|
||||
// lifecycle events (cached/downloaded/scan_*) reach connected clients.
|
||||
// websocket.Server satisfies events.Broadcaster via BroadcastEvent.
|
||||
a.cache.SetBroadcaster(a.wsServer)
|
||||
a.scanManager.SetBroadcaster(a.wsServer)
|
||||
|
||||
// Initialize pre-warming worker. Operator-controlled via cfg.Prewarming;
|
||||
// defaults are baked into config.Default() so unset fields stay sane.
|
||||
log.Info().Msg("Initializing pre-warming worker")
|
||||
a.prewarmWorker = prewarming.NewWorker(prewarming.Config{
|
||||
Enabled: false, // Disabled by default
|
||||
Interval: 1 * time.Hour,
|
||||
MaxConcurrent: 5,
|
||||
Enabled: a.config.Prewarming.Enabled,
|
||||
Interval: a.config.Prewarming.Interval,
|
||||
MaxConcurrent: a.config.Prewarming.MaxConcurrent,
|
||||
TopPackages: a.config.Prewarming.TopPackages,
|
||||
CacheManager: a.cache,
|
||||
Analytics: a.analyticsEngine,
|
||||
NetworkClient: a.networkClient,
|
||||
@@ -312,29 +388,75 @@ func (a *App) setupServer() error {
|
||||
AppName: "GoHoarder v1.0",
|
||||
})
|
||||
|
||||
// Health and metrics endpoints (adapted from net/http)
|
||||
// Auth middleware factories. Built once, reused on every gated route.
|
||||
// When auth is disabled the variables stay nil and we skip wiring; routes
|
||||
// are mounted bare to preserve backward-compatible public access.
|
||||
authEnabled := a.config.Auth.Enabled
|
||||
var (
|
||||
mwAuth fiber.Handler
|
||||
mwAdmin fiber.Handler
|
||||
mwOptional fiber.Handler
|
||||
)
|
||||
if authEnabled {
|
||||
mwAuth = RequireAuth(a.authManager)
|
||||
mwAdmin = RequireRole(a.authManager, string(auth.RoleAdmin))
|
||||
mwOptional = OptionalAuth(a.authManager)
|
||||
_ = mwOptional // reserved for future endpoints with anonymous fallback
|
||||
}
|
||||
|
||||
// Health endpoints — ALWAYS public (k8s liveness/readiness probes).
|
||||
a.app.Get("/health", adaptor.HTTPHandlerFunc(a.healthChecker.HealthHandler()))
|
||||
a.app.Get("/health/ready", adaptor.HTTPHandlerFunc(a.healthChecker.ReadyHandler()))
|
||||
a.app.Get("/metrics", adaptor.HTTPHandler(metrics.Handler()))
|
||||
|
||||
// WebSocket endpoint (adapted from net/http)
|
||||
a.app.Get("/ws", adaptor.HTTPHandlerFunc(a.wsServer.HandleWebSocket))
|
||||
// Metrics endpoint — public by default, optionally auth-gated.
|
||||
if authEnabled && a.config.Metrics.RequireAuth {
|
||||
a.app.Get("/metrics", mwAuth, adaptor.HTTPHandler(metrics.Handler()))
|
||||
} else {
|
||||
a.app.Get("/metrics", adaptor.HTTPHandler(metrics.Handler()))
|
||||
}
|
||||
|
||||
// API endpoints
|
||||
a.app.Get("/api/config", a.handleConfig)
|
||||
a.app.All("/api/packages/*", a.handlePackages) // Handles packages and vulnerabilities
|
||||
a.app.Get("/api/stats", a.handleStats)
|
||||
a.app.Get("/api/stats/timeseries", a.handleTimeSeriesStats)
|
||||
a.app.Get("/api/info", a.handleInfo)
|
||||
// WebSocket endpoint — gated when auth enabled. Clients must send the
|
||||
// API key via Authorization or X-API-Key on the upgrade request.
|
||||
if authEnabled {
|
||||
a.app.Get("/ws", mwAuth, adaptor.HTTPHandlerFunc(a.wsServer.HandleWebSocket))
|
||||
} else {
|
||||
a.app.Get("/ws", adaptor.HTTPHandlerFunc(a.wsServer.HandleWebSocket))
|
||||
}
|
||||
|
||||
// Analytics endpoints
|
||||
a.app.Get("/api/analytics/top", a.handleAnalyticsTopPackages)
|
||||
a.app.Get("/api/analytics/trending", a.handleAnalyticsTrendingPackages)
|
||||
a.app.Get("/api/analytics/trends", a.handleAnalyticsTrends)
|
||||
a.app.Get("/api/analytics/total", a.handleAnalyticsTotalStats)
|
||||
a.app.Get("/api/analytics/registry/:registry", a.handleAnalyticsRegistryStats)
|
||||
a.app.Get("/api/analytics/package/:registry/:name", a.handleAnalyticsPackageStats)
|
||||
a.app.Get("/api/analytics/search", a.handleAnalyticsSearch)
|
||||
// API endpoints. Read endpoints require any valid key; package DELETE
|
||||
// requires admin (split route from the All() so role-check runs only on
|
||||
// destructive verbs). The combined handler still dispatches by method.
|
||||
if authEnabled {
|
||||
a.app.Get("/api/config", mwAuth, a.handleConfig)
|
||||
a.app.Get("/api/packages/*", mwAuth, a.handlePackages)
|
||||
a.app.Delete("/api/packages/*", mwAdmin, a.handlePackages)
|
||||
a.app.All("/api/packages/*", mwAuth, a.handlePackages) // OPTIONS, preflight, vulnerabilities
|
||||
a.app.Get("/api/stats", mwAuth, a.handleStats)
|
||||
a.app.Get("/api/stats/timeseries", mwAuth, a.handleTimeSeriesStats)
|
||||
a.app.Get("/api/info", mwAuth, a.handleInfo)
|
||||
|
||||
a.app.Get("/api/analytics/top", mwAuth, a.handleAnalyticsTopPackages)
|
||||
a.app.Get("/api/analytics/trending", mwAuth, a.handleAnalyticsTrendingPackages)
|
||||
a.app.Get("/api/analytics/trends", mwAuth, a.handleAnalyticsTrends)
|
||||
a.app.Get("/api/analytics/total", mwAuth, a.handleAnalyticsTotalStats)
|
||||
a.app.Get("/api/analytics/registry/:registry", mwAuth, a.handleAnalyticsRegistryStats)
|
||||
a.app.Get("/api/analytics/package/:registry/:name", mwAuth, a.handleAnalyticsPackageStats)
|
||||
a.app.Get("/api/analytics/search", mwAuth, a.handleAnalyticsSearch)
|
||||
} else {
|
||||
a.app.Get("/api/config", a.handleConfig)
|
||||
a.app.All("/api/packages/*", a.handlePackages)
|
||||
a.app.Get("/api/stats", a.handleStats)
|
||||
a.app.Get("/api/stats/timeseries", a.handleTimeSeriesStats)
|
||||
a.app.Get("/api/info", a.handleInfo)
|
||||
|
||||
a.app.Get("/api/analytics/top", a.handleAnalyticsTopPackages)
|
||||
a.app.Get("/api/analytics/trending", a.handleAnalyticsTrendingPackages)
|
||||
a.app.Get("/api/analytics/trends", a.handleAnalyticsTrends)
|
||||
a.app.Get("/api/analytics/total", a.handleAnalyticsTotalStats)
|
||||
a.app.Get("/api/analytics/registry/:registry", a.handleAnalyticsRegistryStats)
|
||||
a.app.Get("/api/analytics/package/:registry/:name", a.handleAnalyticsPackageStats)
|
||||
a.app.Get("/api/analytics/search", a.handleAnalyticsSearch)
|
||||
}
|
||||
|
||||
// Admin endpoints (bypass management)
|
||||
a.app.All("/api/admin/bypasses/:id?", a.requireAdmin, a.handleAdminBypasses)
|
||||
@@ -368,28 +490,62 @@ func (a *App) setupServer() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Go proxy with CDN caching
|
||||
goProxyHandler := goproxy.New(a.cache, a.networkClient, goproxy.Config{
|
||||
Upstream: "https://proxy.golang.org",
|
||||
SumDBURL: "https://sum.golang.org",
|
||||
CredStore: credStore,
|
||||
})
|
||||
goProxyWithCDN := a.cdnMiddleware.Handler(http.StripPrefix("/go", goProxyHandler))
|
||||
a.app.All("/go/*", adaptor.HTTPHandler(goProxyWithCDN))
|
||||
// Per-registry proxies. Mounted only when the corresponding handler is
|
||||
// enabled in config. Each Upstream falls back to the canonical URL if
|
||||
// unset so partially-configured deployments keep working.
|
||||
if a.config.Handlers.Go.Enabled {
|
||||
goUpstream := a.config.Handlers.Go.UpstreamProxy
|
||||
if goUpstream == "" {
|
||||
goUpstream = "https://proxy.golang.org"
|
||||
}
|
||||
sumDB := a.config.Handlers.Go.ChecksumDB
|
||||
if sumDB == "" {
|
||||
sumDB = "https://sum.golang.org"
|
||||
}
|
||||
goProxyHandler := goproxy.New(a.cache, a.networkClient, goproxy.Config{
|
||||
Upstream: goUpstream,
|
||||
SumDBURL: sumDB,
|
||||
CredStore: credStore,
|
||||
})
|
||||
goProxyWithCDN := a.cdnMiddleware.Handler(http.StripPrefix("/go", goProxyHandler))
|
||||
if authEnabled {
|
||||
a.app.All("/go/*", mwAuth, adaptor.HTTPHandler(goProxyWithCDN))
|
||||
} else {
|
||||
a.app.All("/go/*", adaptor.HTTPHandler(goProxyWithCDN))
|
||||
}
|
||||
}
|
||||
|
||||
// NPM proxy with CDN caching
|
||||
npmProxyHandler := npm.New(a.cache, a.networkClient, npm.Config{
|
||||
Upstream: "https://registry.npmjs.org",
|
||||
})
|
||||
npmProxyWithCDN := a.cdnMiddleware.Handler(http.StripPrefix("/npm", npmProxyHandler))
|
||||
a.app.All("/npm/*", adaptor.HTTPHandler(npmProxyWithCDN))
|
||||
if a.config.Handlers.NPM.Enabled {
|
||||
npmUpstream := a.config.Handlers.NPM.UpstreamRegistry
|
||||
if npmUpstream == "" {
|
||||
npmUpstream = "https://registry.npmjs.org"
|
||||
}
|
||||
npmProxyHandler := npm.New(a.cache, a.networkClient, npm.Config{
|
||||
Upstream: npmUpstream,
|
||||
})
|
||||
npmProxyWithCDN := a.cdnMiddleware.Handler(http.StripPrefix("/npm", npmProxyHandler))
|
||||
if authEnabled {
|
||||
a.app.All("/npm/*", mwAuth, adaptor.HTTPHandler(npmProxyWithCDN))
|
||||
} else {
|
||||
a.app.All("/npm/*", adaptor.HTTPHandler(npmProxyWithCDN))
|
||||
}
|
||||
}
|
||||
|
||||
// PyPI proxy with CDN caching
|
||||
pypiProxyHandler := pypi.New(a.cache, a.networkClient, pypi.Config{
|
||||
Upstream: "https://pypi.org/simple",
|
||||
})
|
||||
pypiProxyWithCDN := a.cdnMiddleware.Handler(http.StripPrefix("/pypi", pypiProxyHandler))
|
||||
a.app.All("/pypi/*", adaptor.HTTPHandler(pypiProxyWithCDN))
|
||||
if a.config.Handlers.PyPI.Enabled {
|
||||
pypiUpstream := a.config.Handlers.PyPI.SimpleAPIURL
|
||||
if pypiUpstream == "" {
|
||||
pypiUpstream = "https://pypi.org/simple"
|
||||
}
|
||||
pypiProxyHandler := pypi.New(a.cache, a.networkClient, pypi.Config{
|
||||
Upstream: pypiUpstream,
|
||||
})
|
||||
pypiProxyWithCDN := a.cdnMiddleware.Handler(http.StripPrefix("/pypi", pypiProxyHandler))
|
||||
if authEnabled {
|
||||
a.app.All("/pypi/*", mwAuth, adaptor.HTTPHandler(pypiProxyWithCDN))
|
||||
} else {
|
||||
a.app.All("/pypi/*", adaptor.HTTPHandler(pypiProxyWithCDN))
|
||||
}
|
||||
}
|
||||
|
||||
// Serve frontend static files
|
||||
frontendDir := "frontend/dist"
|
||||
@@ -442,10 +598,24 @@ func (a *App) Run() error {
|
||||
// Start download data aggregation worker (runs every hour)
|
||||
go a.startAggregationWorker(ctx)
|
||||
|
||||
// Start Fiber server in goroutine
|
||||
// Start periodic stats broadcaster (every 30s) so connected WS clients
|
||||
// see live counter updates without polling.
|
||||
go a.startStatsBroadcaster(ctx)
|
||||
|
||||
// Start Fiber server in goroutine. Branch on TLS to pick Listen vs ListenTLS.
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
addr := fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port)
|
||||
if a.config.Server.TLS.Enabled {
|
||||
log.Info().
|
||||
Str("addr", addr).
|
||||
Str("cert_file", a.config.Server.TLS.CertFile).
|
||||
Msg("Starting Fiber server with TLS")
|
||||
if err := a.app.ListenTLS(addr, a.config.Server.TLS.CertFile, a.config.Server.TLS.KeyFile); err != nil {
|
||||
errChan <- err
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Info().
|
||||
Str("addr", addr).
|
||||
Msg("Starting Fiber server")
|
||||
@@ -480,6 +650,11 @@ func (a *App) Shutdown() error {
|
||||
log.Error().Err(err).Msg("Error shutting down Fiber server")
|
||||
}
|
||||
|
||||
// Drain async auth writes (LastUsedAt updates) before closing metadata.
|
||||
if a.authManager != nil {
|
||||
a.authManager.Close()
|
||||
}
|
||||
|
||||
// Stop pre-warming worker
|
||||
a.prewarmWorker.Stop()
|
||||
|
||||
@@ -505,6 +680,46 @@ func (a *App) Shutdown() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// startStatsBroadcaster periodically pulls aggregate cache stats and pushes
|
||||
// them onto the WebSocket broadcast channel so connected dashboards see
|
||||
// live counters without polling. Lightweight: a single GetStats call every
|
||||
// 30s, drop-on-overflow at the WS layer.
|
||||
func (a *App) startStatsBroadcaster(ctx context.Context) {
|
||||
if a.wsServer == nil {
|
||||
return
|
||||
}
|
||||
const interval = 30 * time.Second
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
emit := func() {
|
||||
statsCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
stats, err := a.cache.GetStats(statsCtx, "")
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("stats broadcaster: GetStats failed")
|
||||
return
|
||||
}
|
||||
a.wsServer.BroadcastEvent("stats_update", map[string]interface{}{
|
||||
"stats": stats,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// Emit once immediately so newly-connected clients see fresh data.
|
||||
emit()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Info().Msg("Stats broadcaster stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
emit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startAggregationWorker runs download data aggregation periodically
|
||||
func (a *App) startAggregationWorker(ctx context.Context) {
|
||||
log.Info().Msg("Starting download data aggregation worker (runs every hour)")
|
||||
@@ -546,3 +761,85 @@ func getOrDefaultStr(value, defaultValue string) string {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// buildCheckOrigin returns a websocket Origin validator.
|
||||
//
|
||||
// Behaviour:
|
||||
// - allowed entries may be exact origins ("https://app.example.com")
|
||||
// or wildcard host patterns ("https://*.example.com").
|
||||
// - when allowed is empty, only same-origin upgrades are permitted: the
|
||||
// Origin host must match the Host header of the incoming request.
|
||||
// - requests with no Origin header are accepted (non-browser clients).
|
||||
//
|
||||
// We deliberately do NOT default to allow-all to avoid Cross-Site WebSocket
|
||||
// Hijacking (CSWSH).
|
||||
func buildCheckOrigin(allowed []string) func(*http.Request) bool {
|
||||
// Pre-parse allowlist once.
|
||||
type originRule struct {
|
||||
scheme string
|
||||
host string // exact host, or "" if hostSuffix is set
|
||||
hostGlob string // suffix match including leading "."
|
||||
}
|
||||
rules := make([]originRule, 0, len(allowed))
|
||||
for _, raw := range allowed {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
log.Warn().Str("entry", raw).Msg("Ignoring invalid AllowedOrigins entry")
|
||||
continue
|
||||
}
|
||||
r := originRule{scheme: strings.ToLower(u.Scheme)}
|
||||
host := strings.ToLower(u.Host)
|
||||
if strings.HasPrefix(host, "*.") {
|
||||
// "*.example.com" → match any subdomain plus the apex.
|
||||
r.hostGlob = host[1:] // ".example.com"
|
||||
} else {
|
||||
r.host = host
|
||||
}
|
||||
rules = append(rules, r)
|
||||
}
|
||||
|
||||
return func(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
// Non-browser clients (e.g., CLI, server-to-server) omit Origin.
|
||||
return true
|
||||
}
|
||||
ou, err := url.Parse(origin)
|
||||
if err != nil || ou.Scheme == "" || ou.Host == "" {
|
||||
log.Warn().Str("origin", origin).Msg("Rejecting WebSocket upgrade: malformed Origin header")
|
||||
return false
|
||||
}
|
||||
originScheme := strings.ToLower(ou.Scheme)
|
||||
originHost := strings.ToLower(ou.Host)
|
||||
|
||||
// Empty allowlist → same-origin only.
|
||||
if len(rules) == 0 {
|
||||
return strings.EqualFold(originHost, r.Host)
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if rule.scheme != originScheme {
|
||||
continue
|
||||
}
|
||||
if rule.host != "" && rule.host == originHost {
|
||||
return true
|
||||
}
|
||||
if rule.hostGlob != "" {
|
||||
// Match apex (".example.com" → "example.com") and any subdomain.
|
||||
apex := strings.TrimPrefix(rule.hostGlob, ".")
|
||||
if originHost == apex || strings.HasSuffix(originHost, rule.hostGlob) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Warn().
|
||||
Str("origin", origin).
|
||||
Str("host", r.Host).
|
||||
Msg("Rejecting WebSocket upgrade: Origin not in allowlist")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,9 +286,9 @@ func (s *AnalyticsHandlersTestSuite) TestHandleAnalyticsSearch() {
|
||||
|
||||
if !tt.expectError {
|
||||
var result struct {
|
||||
Query string `json:"query"`
|
||||
Results []analytics.PackageStats `json:"results"`
|
||||
Total int `json:"total"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
s.NoError(err)
|
||||
|
||||
@@ -44,8 +44,8 @@ func (s *AuthHandlersTestSuite) TestHandleGenerateAPIKey() {
|
||||
tests := []struct {
|
||||
requestBody map[string]string
|
||||
name string
|
||||
expectedStatus int
|
||||
expectedRole string
|
||||
expectedStatus int
|
||||
expectKey bool
|
||||
}{
|
||||
{
|
||||
@@ -139,9 +139,9 @@ func (s *AuthHandlersTestSuite) TestHandleGenerateAPIKey() {
|
||||
|
||||
func (s *AuthHandlersTestSuite) TestHandleListAPIKeys() {
|
||||
// Generate some test keys first
|
||||
s.authManager.GenerateAPIKey("test-key-1", auth.RoleReadOnly, nil)
|
||||
s.authManager.GenerateAPIKey("test-key-2", auth.RoleReadWrite, nil)
|
||||
s.authManager.GenerateAPIKey("test-key-3", auth.RoleAdmin, nil)
|
||||
_, _, _ = s.authManager.GenerateAPIKey("test-key-1", auth.RoleReadOnly, nil)
|
||||
_, _, _ = s.authManager.GenerateAPIKey("test-key-2", auth.RoleReadWrite, nil)
|
||||
_, _, _ = s.authManager.GenerateAPIKey("test-key-3", auth.RoleAdmin, nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/admin/keys", nil)
|
||||
resp, err := s.app.Test(req, 5000) // 5 second timeout for CI environments
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/auth"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Locals keys used by the auth middleware. Exported so other handlers in the
|
||||
// app package (and the integrator) can fetch the resolved key/role without
|
||||
// guessing at string literals.
|
||||
const (
|
||||
LocalAuthKey = "auth_key"
|
||||
LocalAuthRole = "auth_role"
|
||||
)
|
||||
|
||||
// extractAPIKey pulls the raw API key from either the Authorization bearer
|
||||
// header or the X-API-Key header. Returns ("", false) when neither is set or
|
||||
// the Authorization header has the wrong shape.
|
||||
func extractAPIKey(c *fiber.Ctx) (string, bool) {
|
||||
if h := strings.TrimSpace(c.Get("Authorization")); h != "" {
|
||||
// Expect: "Bearer <token>". Be tolerant of casing on the scheme.
|
||||
parts := strings.SplitN(h, " ", 2)
|
||||
if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") {
|
||||
token := strings.TrimSpace(parts[1])
|
||||
if token != "" {
|
||||
return token, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if h := strings.TrimSpace(c.Get("X-API-Key")); h != "" {
|
||||
return h, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// writeAuthError sends a structured error response matching the project's
|
||||
// errors envelope (errors.Error JSON shape). Uses the HTTPStatusCode map so
|
||||
// callers don't need to remember which status maps to which code.
|
||||
func writeAuthError(c *fiber.Ctx, code, message string) error {
|
||||
status, ok := errors.HTTPStatusCode[code]
|
||||
if !ok {
|
||||
status = fiber.StatusInternalServerError
|
||||
}
|
||||
return c.Status(status).JSON(errors.New(code, message))
|
||||
}
|
||||
|
||||
// requestIDFromCtx returns the request ID set by an upstream middleware or
|
||||
// the X-Request-ID header. Empty string when neither is available — callers
|
||||
// should treat empty as "no correlation id".
|
||||
func requestIDFromCtx(c *fiber.Ctx) string {
|
||||
if v := c.Locals("request_id"); v != nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return c.Get("X-Request-ID")
|
||||
}
|
||||
|
||||
// validateAndAttach is the shared core of all auth middlewares: pull the key,
|
||||
// validate via the manager, and on success store it in Locals. Returns the
|
||||
// resolved key plus a boolean indicating whether a key was present at all
|
||||
// (false when no header). The error is non-nil only when validation failed
|
||||
// (i.e. a key was present but invalid/expired).
|
||||
func validateAndAttach(c *fiber.Ctx, authMgr *auth.Manager) (key *auth.APIKey, present bool, err error) {
|
||||
rawKey, ok := extractAPIKey(c)
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
apiKey, err := authMgr.ValidateAPIKey(c.Context(), rawKey)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
c.Locals(LocalAuthKey, apiKey)
|
||||
c.Locals(LocalAuthRole, string(apiKey.Role))
|
||||
return apiKey, true, nil
|
||||
}
|
||||
|
||||
// RequireAuth returns a fiber.Handler that rejects requests without a valid
|
||||
// API key. On success the resolved *auth.APIKey is stored in c.Locals under
|
||||
// LocalAuthKey.
|
||||
func RequireAuth(authMgr *auth.Manager) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
key, present, err := validateAndAttach(c, authMgr)
|
||||
if !present {
|
||||
log.Debug().
|
||||
Str("path", c.Path()).
|
||||
Str("method", c.Method()).
|
||||
Str("request_id", requestIDFromCtx(c)).
|
||||
Msg("auth: missing API key")
|
||||
return writeAuthError(c, errors.ErrCodeUnauthorized, "missing API key; provide Authorization: Bearer <key> or X-API-Key header")
|
||||
}
|
||||
if err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("path", c.Path()).
|
||||
Str("method", c.Method()).
|
||||
Str("request_id", requestIDFromCtx(c)).
|
||||
Msg("auth: invalid API key")
|
||||
return writeAuthError(c, errors.ErrCodeInvalidAPIKey, "invalid or expired API key")
|
||||
}
|
||||
log.Debug().
|
||||
Str("key_id", key.ID).
|
||||
Str("role", string(key.Role)).
|
||||
Str("request_id", requestIDFromCtx(c)).
|
||||
Msg("auth: key validated")
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireRole returns a fiber.Handler that authenticates the caller and then
|
||||
// requires the resolved key's role to be present in the supplied list (any-of
|
||||
// semantics). An empty roles list behaves like RequireAuth.
|
||||
func RequireRole(authMgr *auth.Manager, roles ...string) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
key, present, err := validateAndAttach(c, authMgr)
|
||||
if !present {
|
||||
return writeAuthError(c, errors.ErrCodeUnauthorized, "missing API key; provide Authorization: Bearer <key> or X-API-Key header")
|
||||
}
|
||||
if err != nil {
|
||||
return writeAuthError(c, errors.ErrCodeInvalidAPIKey, "invalid or expired API key")
|
||||
}
|
||||
if len(roles) == 0 {
|
||||
return c.Next()
|
||||
}
|
||||
actual := string(key.Role)
|
||||
for _, r := range roles {
|
||||
if r == actual {
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
log.Warn().
|
||||
Str("key_id", key.ID).
|
||||
Str("role", actual).
|
||||
Strs("required_roles", roles).
|
||||
Str("request_id", requestIDFromCtx(c)).
|
||||
Msg("auth: role mismatch")
|
||||
return writeAuthError(c, errors.ErrCodeForbidden, "insufficient role for this resource")
|
||||
}
|
||||
}
|
||||
|
||||
// RequirePermission returns a fiber.Handler that authenticates the caller and
|
||||
// then requires the resolved key to hold at least one of the supplied
|
||||
// permissions. An empty perms list behaves like RequireAuth.
|
||||
func RequirePermission(authMgr *auth.Manager, perms ...string) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
key, present, err := validateAndAttach(c, authMgr)
|
||||
if !present {
|
||||
return writeAuthError(c, errors.ErrCodeUnauthorized, "missing API key; provide Authorization: Bearer <key> or X-API-Key header")
|
||||
}
|
||||
if err != nil {
|
||||
return writeAuthError(c, errors.ErrCodeInvalidAPIKey, "invalid or expired API key")
|
||||
}
|
||||
if len(perms) == 0 {
|
||||
return c.Next()
|
||||
}
|
||||
for _, p := range perms {
|
||||
if key.HasPermission(auth.Permission(p)) {
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
log.Warn().
|
||||
Str("key_id", key.ID).
|
||||
Str("role", string(key.Role)).
|
||||
Strs("required_perms", perms).
|
||||
Str("request_id", requestIDFromCtx(c)).
|
||||
Msg("auth: permission denied")
|
||||
return writeAuthError(c, errors.ErrCodeForbidden, "insufficient permissions for this resource")
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuth returns a fiber.Handler that attempts to validate any provided
|
||||
// API key but never rejects the request. Handy for endpoints whose behavior
|
||||
// changes when the caller is authenticated (e.g. per-key rate limits) but
|
||||
// which still serve anonymous traffic. Locals are populated on success.
|
||||
func OptionalAuth(authMgr *auth.Manager) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
_, present, err := validateAndAttach(c, authMgr)
|
||||
if present && err != nil {
|
||||
// Key was provided but invalid — log for observability, continue
|
||||
// anonymously rather than 401-ing.
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Str("path", c.Path()).
|
||||
Str("request_id", requestIDFromCtx(c)).
|
||||
Msg("auth: optional auth ignored invalid key")
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/auth"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mwTestSetup builds a fiber app wired with the given middleware on /protected.
|
||||
// The handler echoes the resolved auth_key locals so tests can assert that
|
||||
// downstream handlers see them. Returns the app plus a freshly issued raw key
|
||||
// for the supplied role.
|
||||
func mwTestSetup(t *testing.T, role auth.Role, mw fiber.Handler) (*fiber.App, *auth.Manager, string, *auth.APIKey) {
|
||||
t.Helper()
|
||||
mgr := auth.New()
|
||||
apiKey, raw, err := mgr.GenerateAPIKey("test-"+string(role), role, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Use("/protected", mw)
|
||||
app.Get("/protected", func(c *fiber.Ctx) error {
|
||||
got, _ := c.Locals(LocalAuthKey).(*auth.APIKey)
|
||||
gotRole, _ := c.Locals(LocalAuthRole).(string)
|
||||
body := fiber.Map{"ok": true, "have_key": got != nil}
|
||||
if got != nil {
|
||||
body["key_id"] = got.ID
|
||||
body["role"] = gotRole
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(body)
|
||||
})
|
||||
return app, mgr, raw, apiKey
|
||||
}
|
||||
|
||||
func decodeErrorBody(t *testing.T, body io.Reader) errors.Error {
|
||||
t.Helper()
|
||||
var e errors.Error
|
||||
require.NoError(t, json.NewDecoder(body).Decode(&e))
|
||||
return e
|
||||
}
|
||||
|
||||
func TestRequireAuth_NoHeader_Returns401(t *testing.T) {
|
||||
app, _, _, _ := mwTestSetup(t, auth.RoleReadOnly, RequireAuth(auth.New()))
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
resp, err := app.Test(req, 5000)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusUnauthorized, resp.StatusCode)
|
||||
body := decodeErrorBody(t, resp.Body)
|
||||
require.Equal(t, errors.ErrCodeUnauthorized, body.Code)
|
||||
}
|
||||
|
||||
func TestRequireAuth_BadKey_Returns401(t *testing.T) {
|
||||
mgr := auth.New()
|
||||
_, _, err := mgr.GenerateAPIKey("real", auth.RoleReadOnly, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
app, _, _, _ := mwTestSetup(t, auth.RoleReadOnly, RequireAuth(mgr))
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer not-a-real-key")
|
||||
resp, err := app.Test(req, 5000)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusUnauthorized, resp.StatusCode)
|
||||
body := decodeErrorBody(t, resp.Body)
|
||||
require.Equal(t, errors.ErrCodeInvalidAPIKey, body.Code)
|
||||
}
|
||||
|
||||
func TestRequireAuth_BadHeaderShape_Returns401(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
header string
|
||||
value string
|
||||
}{
|
||||
{"non-bearer scheme", "Authorization", "Basic abcdef"},
|
||||
{"bearer no token", "Authorization", "Bearer "},
|
||||
{"empty x-api-key", "X-API-Key", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mgr := auth.New()
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Use("/protected", RequireAuth(mgr))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(200) })
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
if tc.value != "" {
|
||||
req.Header.Set(tc.header, tc.value)
|
||||
}
|
||||
resp, err := app.Test(req, 5000)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuth_ValidBearer_Returns200_AndPopulatesLocals(t *testing.T) {
|
||||
mgr := auth.New()
|
||||
apiKey, raw, err := mgr.GenerateAPIKey("good", auth.RoleReadWrite, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Use("/protected", RequireAuth(mgr))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error {
|
||||
k, _ := c.Locals(LocalAuthKey).(*auth.APIKey)
|
||||
require.NotNil(t, k)
|
||||
require.Equal(t, apiKey.ID, k.ID)
|
||||
require.Equal(t, string(auth.RoleReadWrite), c.Locals(LocalAuthRole))
|
||||
return c.Status(200).JSON(fiber.Map{"key_id": k.ID})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
resp, err := app.Test(req, 5000)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, apiKey.ID, body["key_id"])
|
||||
}
|
||||
|
||||
func TestRequireAuth_ValidXAPIKey_Returns200(t *testing.T) {
|
||||
mgr := auth.New()
|
||||
_, raw, err := mgr.GenerateAPIKey("good", auth.RoleReadOnly, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Use("/protected", RequireAuth(mgr))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(200) })
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("X-API-Key", raw)
|
||||
resp, err := app.Test(req, 5000)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestRequireAuth_ExpiredKey_Returns401(t *testing.T) {
|
||||
mgr := auth.New()
|
||||
d := -1 * time.Hour
|
||||
_, raw, err := mgr.GenerateAPIKey("expired", auth.RoleReadOnly, &d)
|
||||
require.NoError(t, err)
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Use("/protected", RequireAuth(mgr))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(200) })
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
resp, err := app.Test(req, 5000)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestRequireRole_Match_Returns200(t *testing.T) {
|
||||
mgr := auth.New()
|
||||
_, raw, err := mgr.GenerateAPIKey("admin", auth.RoleAdmin, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Use("/protected", RequireRole(mgr, string(auth.RoleAdmin), string(auth.RoleReadWrite)))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(200) })
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
resp, err := app.Test(req, 5000)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestRequireRole_Mismatch_Returns403(t *testing.T) {
|
||||
mgr := auth.New()
|
||||
_, raw, err := mgr.GenerateAPIKey("ro", auth.RoleReadOnly, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Use("/protected", RequireRole(mgr, string(auth.RoleAdmin)))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(200) })
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
resp, err := app.Test(req, 5000)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusForbidden, resp.StatusCode)
|
||||
body := decodeErrorBody(t, resp.Body)
|
||||
require.Equal(t, errors.ErrCodeForbidden, body.Code)
|
||||
}
|
||||
|
||||
func TestRequireRole_NoHeader_Returns401(t *testing.T) {
|
||||
mgr := auth.New()
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Use("/protected", RequireRole(mgr, string(auth.RoleAdmin)))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(200) })
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
resp, err := app.Test(req, 5000)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestRequirePermission_Match_Returns200(t *testing.T) {
|
||||
mgr := auth.New()
|
||||
_, raw, err := mgr.GenerateAPIKey("rw", auth.RoleReadWrite, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Use("/protected", RequirePermission(mgr, string(auth.PermissionWritePackage)))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(200) })
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
resp, err := app.Test(req, 5000)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestRequirePermission_Mismatch_Returns403(t *testing.T) {
|
||||
mgr := auth.New()
|
||||
_, raw, err := mgr.GenerateAPIKey("ro", auth.RoleReadOnly, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Use("/protected", RequirePermission(mgr, string(auth.PermissionDeletePackage)))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error { return c.SendStatus(200) })
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
resp, err := app.Test(req, 5000)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fiber.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestOptionalAuth_NoHeader_Continues(t *testing.T) {
|
||||
mgr := auth.New()
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Use("/protected", OptionalAuth(mgr))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error {
|
||||
require.Nil(t, c.Locals(LocalAuthKey))
|
||||
return c.SendStatus(200)
|
||||
})
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
resp, err := app.Test(req, 5000)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestOptionalAuth_BadKey_ContinuesAnonymously(t *testing.T) {
|
||||
mgr := auth.New()
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Use("/protected", OptionalAuth(mgr))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error {
|
||||
// Bad key should NOT populate locals.
|
||||
require.Nil(t, c.Locals(LocalAuthKey))
|
||||
return c.SendStatus(200)
|
||||
})
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer not-real")
|
||||
resp, err := app.Test(req, 5000)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestOptionalAuth_GoodKey_PopulatesLocals(t *testing.T) {
|
||||
mgr := auth.New()
|
||||
apiKey, raw, err := mgr.GenerateAPIKey("good", auth.RoleAdmin, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Use("/protected", OptionalAuth(mgr))
|
||||
app.Get("/protected", func(c *fiber.Ctx) error {
|
||||
k, _ := c.Locals(LocalAuthKey).(*auth.APIKey)
|
||||
require.NotNil(t, k)
|
||||
require.Equal(t, apiKey.ID, k.ID)
|
||||
return c.SendStatus(200)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
resp, err := app.Test(req, 5000)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
Reference in New Issue
Block a user