diff --git a/.gitignore b/.gitignore index 11fa614..93de015 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist-ssr .nitro .tanstack bin +.claude/settings.local.json diff --git a/backend/Justfile b/backend/Justfile index ee1432e..7ea6c7f 100644 --- a/backend/Justfile +++ b/backend/Justfile @@ -1,8 +1,21 @@ set dotenv-load := true +set windows-shell := ["pwsh", "-NoLogo", "-NoProfile", "-Command"] -VERSION := `git describe --tags --always --dirty 2>/dev/null || echo "dev"` -COMMIT := `git rev-parse --short HEAD 2>/dev/null || echo "none"` -DATE := `date -u +%Y-%m-%dT%H:%M:%SZ` +VERSION := if os_family() == 'windows' { + `git describe --tags --always --dirty 2>$null; if (-not $?) { "dev" }` +} else { + `git describe --tags --always --dirty 2>/dev/null || echo "dev"` +} +COMMIT := if os_family() == 'windows' { + `git rev-parse --short HEAD 2>$null; if (-not $?) { "none" }` +} else { + `git rev-parse --short HEAD 2>/dev/null || echo "none"` +} +DATE := if os_family() == 'windows' { + `(Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')` +} else { + `date -u +%Y-%m-%dT%H:%M:%SZ` +} LDFLAGS := "-s -w -X github.com/OrcaCD/orca-cd/internal/version.Version=" + VERSION + " -X github.com/OrcaCD/orca-cd/internal/version.Commit=" + COMMIT + " -X github.com/OrcaCD/orca-cd/internal/version.BuildDate=" + DATE defaultCommand := "" @@ -25,13 +38,15 @@ test: # Build the hub binary to bin/hub [group('hub')] +[env("CGO_ENABLED", "1")] build-hub: - CGO_ENABLED=1 go build -ldflags '{{ LDFLAGS }}' -o bin/hub ./cmd/hub + go build -ldflags '{{ LDFLAGS }}' -o bin/hub ./cmd/hub # Build the agent binary to bin/agent [group('agent')] +[env("CGO_ENABLED", "1")] build-agent: - CGO_ENABLED=1 go build -ldflags '{{ LDFLAGS }}' -o bin/agent ./cmd/agent + go build -ldflags '{{ LDFLAGS }}' -o bin/agent ./cmd/agent # Build both hub and agent binaries in parallel [parallel] @@ -39,13 +54,15 @@ build: build-hub build-agent # Run the hub (use the help command to get a list of commands) [group('hub')] +[env("CGO_ENABLED", "1")] run-hub cmd=defaultCommand: - CGO_ENABLED=1 go run -ldflags '{{ LDFLAGS }}' ./cmd/hub {{ cmd }} + go run -ldflags '{{ LDFLAGS }}' ./cmd/hub {{ cmd }} # Run the agent (use the help command to get a list of commands) [group('agent')] +[env("CGO_ENABLED", "1")] run-agent cmd=defaultCommand: - CGO_ENABLED=1 go run -ldflags '{{ LDFLAGS }}' ./cmd/agent {{ cmd }} + go run -ldflags '{{ LDFLAGS }}' ./cmd/agent {{ cmd }} # Run both hub and agent in parallel [parallel] diff --git a/backend/cmd/hub/main.go b/backend/cmd/hub/main.go index d4ffdfc..cd7e114 100644 --- a/backend/cmd/hub/main.go +++ b/backend/cmd/hub/main.go @@ -16,7 +16,11 @@ func main() { Short: "Orca Hub", DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { - return hub.Run(hub.DefaultConfig()) + cfg, err := hub.DefaultConfig() + if err != nil { + return err + } + return hub.Run(cfg) }, } @@ -32,7 +36,11 @@ func main() { Use: "healthcheck", Short: "Check the health of the hub", Run: func(cmd *cobra.Command, args []string) { - cfg := hub.DefaultConfig() + cfg, err := hub.DefaultConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "invalid configuration: %v\n", err) + os.Exit(1) + } //nolint:gosec resp, err := http.Get("http://localhost:" + cfg.Port + "/api/v1/health") diff --git a/backend/internal/hub/middleware/csrf.go b/backend/internal/hub/middleware/csrf.go new file mode 100644 index 0000000..ae5d94c --- /dev/null +++ b/backend/internal/hub/middleware/csrf.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +var safeMethods = map[string]bool{ + http.MethodGet: true, + http.MethodHead: true, + http.MethodOptions: true, +} + +func ValidateOrigin(appURL string) gin.HandlerFunc { + return func(c *gin.Context) { + if safeMethods[c.Request.Method] { + c.Next() + return + } + + origin := strings.TrimSuffix(c.GetHeader("Origin"), "/") + if origin == "" { + c.String(http.StatusForbidden, "403 forbidden: missing origin header") + c.Abort() + return + } + + if origin != appURL { + c.String(http.StatusForbidden, "403 forbidden: invalid origin") + c.Abort() + return + } + + c.Next() + } +} diff --git a/backend/internal/hub/middleware/security.go b/backend/internal/hub/middleware/security.go new file mode 100644 index 0000000..3c5a6ed --- /dev/null +++ b/backend/internal/hub/middleware/security.go @@ -0,0 +1,18 @@ +package middleware + +import "github.com/gin-gonic/gin" + +func SecurityHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("X-Frame-Options", "DENY") + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Referrer-Policy", "same-origin") + c.Header("X-Robots-Tag", "noindex, nofollow, noarchive") + c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; object-src 'none'") + c.Header("Cross-Origin-Opener-Policy", "same-origin") + c.Header("Cross-Origin-Resource-Policy", "same-origin") + c.Header("Cross-Origin-Embedder-Policy", "require-corp") + + c.Next() + } +} diff --git a/backend/internal/hub/server.go b/backend/internal/hub/server.go index 7f31938..8ecbf98 100644 --- a/backend/internal/hub/server.go +++ b/backend/internal/hub/server.go @@ -2,21 +2,27 @@ package hub import ( "os" + "strings" "time" + "github.com/OrcaCD/orca-cd/internal/hub/middleware" "github.com/OrcaCD/orca-cd/internal/version" "github.com/gin-gonic/gin" "github.com/rs/zerolog" ) type Config struct { - Debug bool - Port string - LogLevel zerolog.Level + Debug bool + Host string + Port string + LogLevel zerolog.Level + TrustedProxies []string + AppURL string } -func DefaultConfig() Config { +func DefaultConfig() (Config, error) { debug := os.Getenv("DEBUG") + host := os.Getenv("HOST") port := os.Getenv("PORT") logLevelStr := os.Getenv("LOG_LEVEL") @@ -29,11 +35,26 @@ func DefaultConfig() Config { logLevel = zerolog.InfoLevel } - return Config{ - Debug: debug == "true", - Port: port, - LogLevel: logLevel, + var trustedProxies []string + for entry := range strings.SplitSeq(os.Getenv("TRUSTED_PROXIES"), ",") { + if s := strings.TrimSpace(entry); s != "" { + trustedProxies = append(trustedProxies, s) + } + } + + appURL, err := parseAppURL(os.Getenv("APP_URL")) + if err != nil { + return Config{}, err } + + return Config{ + Debug: debug == "true", + Host: host, + Port: port, + LogLevel: logLevel, + TrustedProxies: trustedProxies, + AppURL: appURL, + }, nil } var Log = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}). @@ -47,9 +68,18 @@ func Run(cfg Config) error { Log = Log.Level(cfg.LogLevel) router := gin.Default() + if err := router.SetTrustedProxies(cfg.TrustedProxies); err != nil { + return err + } + if !cfg.Debug && len(cfg.TrustedProxies) == 0 { + Log.Warn().Msg("no trusted proxies configured; in production the server should always run behind a reverse proxy") + } + router.Use(middleware.SecurityHeaders()) + router.Use(middleware.ValidateOrigin(cfg.AppURL)) RegisterRoutes(router, cfg) - Log.Info().Str("port", cfg.Port).Str("version", version.Version).Msg("hub started") - return router.Run(":" + cfg.Port) + addr := cfg.Host + ":" + cfg.Port + Log.Info().Str("addr", addr).Str("version", version.Version).Msg("hub started") + return router.Run(addr) } diff --git a/backend/internal/hub/url.go b/backend/internal/hub/url.go new file mode 100644 index 0000000..6bae79c --- /dev/null +++ b/backend/internal/hub/url.go @@ -0,0 +1,50 @@ +package hub + +import ( + "errors" + "fmt" + "net/url" + "strings" +) + +var defaultPorts = map[string]string{ + "http": "80", + "https": "443", +} + +// parseAppURL validates and normalises the APP_URL value. +// It must contain a scheme and host, must not contain a path (other than a +// single leading slash which is stripped), and must not contain a query or +// fragment. +func parseAppURL(raw string) (string, error) { + if raw == "" { + return "", errors.New("APP_URL is required") + } + + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("APP_URL is not a valid URL: %w", err) + } + + if u.Scheme == "" || u.Host == "" { + return "", errors.New("APP_URL must include a scheme and host (e.g. https://example.com)") + } + + path := strings.TrimPrefix(u.Path, "/") + if path != "" { + return "", errors.New("APP_URL must not include a path") + } + + if u.RawQuery != "" || u.Fragment != "" { + return "", errors.New("APP_URL must not include a query string or fragment") + } + + scheme := strings.ToLower(u.Scheme) + host := strings.ToLower(u.Hostname()) + port := u.Port() + if port != "" && port != defaultPorts[scheme] { + host = host + ":" + port + } + + return scheme + "://" + host, nil +} diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: /