Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ platform-api/resources/openapi_with_binding.yaml
platform-api/cmd/cmd
# Compiled platform-api binary (produced by `go build` in src/)
platform-api/main
# Secret/key material and local working-tree checkouts that must never be committed
platform-api/config/data/
platform-api/src/
**/*.key
**/certs/*.pem

# Gateway
gateway/gateway-runtime/target
Expand Down
11 changes: 9 additions & 2 deletions httpkit/middleware/cors.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
// CORSOptions configures CORS behaviour for CORSMiddleware and WithCORS.
type CORSOptions struct {
// AllowedOrigins lists origins that may access the resource.
// Empty (the default) denies all cross-origin access — fail closed.
// Use ["*"] to allow any origin (cannot be combined with AllowCredentials).
AllowedOrigins []string
// AllowedMethods lists the HTTP methods permitted for the resource.
Expand Down Expand Up @@ -71,7 +72,11 @@ func applyCORSHeaders(w http.ResponseWriter, r *http.Request, opts CORSOptions)
w.Header().Add("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)

if opts.AllowCredentials {
// Never pair a wildcard origin with credentials: the combination is spec-invalid
// (browsers reject it for credentialed requests) and, if a browser ever did honor it,
// would let any site read authenticated responses. Only send the credentials header
// when the origin was resolved to a specific, allowlisted value.
if opts.AllowCredentials && allowedOrigin != "*" {
w.Header().Set("Access-Control-Allow-Credentials", "true")
}

Expand All @@ -90,8 +95,10 @@ func applyCORSHeaders(w http.ResponseWriter, r *http.Request, opts CORSOptions)
}

func resolveOrigin(origin string, allowed []string) string {
// Fail closed: an empty allowlist means no cross-origin access, not "allow all".
// Callers must opt in to "*" explicitly.
if len(allowed) == 0 {
return "*"
return ""
}
if slices.Contains(allowed, "*") {
return "*"
Expand Down
13 changes: 13 additions & 0 deletions platform-api/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ type Server struct {
Deployments Deployments `koanf:"deployments"`
ArtifactLimits ArtifactLimits `koanf:"artifact_limits"`
TLS TLS `koanf:"tls"`
CORS CORS `koanf:"cors"`
APIKey APIKey `koanf:"api_key"`
Gateway Gateway `koanf:"gateway"`
EventHub EventHub `koanf:"event_hub"`
Expand Down Expand Up @@ -170,6 +171,14 @@ type TLS struct {
CertDir string `koanf:"cert_dir"`
}

// CORS holds cross-origin resource sharing configuration.
type CORS struct {
// AllowedOrigins lists the exact origins permitted to make credentialed
// cross-origin requests. Must not be ["*"] outside demo mode — wildcard
// origins cannot be combined with credentialed requests.
AllowedOrigins []string `koanf:"allowed_origins"`
}

// JWT holds configuration for local HMAC JWT authentication.
type JWT struct {
Enabled bool `koanf:"enabled"`
Expand Down Expand Up @@ -706,6 +715,10 @@ func envToKoanfKey(s string) string {
case "tls_cert_dir":
return "tls.cert_dir"

// CORS
case "cors_allowed_origins":
return "cors.allowed_origins"

// API Key
case "api_key_hashing_algorithms":
return "api_key.hashing_algorithms"
Expand Down
10 changes: 10 additions & 0 deletions platform-api/config/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_poli
# [tls]
# cert_dir = "/app/data/certs"

# ---------------------------------------------------------------------------
# CORS
# ---------------------------------------------------------------------------
# Required (non-wildcard) when APIP_DEMO_MODE=false. List the exact origins
# (scheme + host + port) that are allowed to make credentialed cross-origin
# requests, e.g. the Developer Portal / Admin UI origins.
# Override with the CORS_ALLOWED_ORIGINS env var (comma-separated).
# [cors]
# allowed_origins = ["https://devportal.example.com", "https://admin.example.com"]

# ---------------------------------------------------------------------------
# DevPortal integration — disable for standalone deployments
# ---------------------------------------------------------------------------
Expand Down
17 changes: 15 additions & 2 deletions platform-api/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
"os"
"os/signal"
"path/filepath"
"slices"
"strings"
"syscall"
"time"
Expand Down Expand Up @@ -87,6 +88,9 @@ func validateAuthConfig(cfg *config.Server) error {
if cfg.Auth.JWT.Enabled && cfg.Auth.JWT.SkipValidation {
return fmt.Errorf("JWT signature validation cannot be skipped (AUTH_JWT_SKIP_VALIDATION=true) when APIP_DEMO_MODE=false; set AUTH_JWT_SKIP_VALIDATION=false for production")
}
if len(cfg.CORS.AllowedOrigins) == 0 || slices.Contains(cfg.CORS.AllowedOrigins, "*") {
return fmt.Errorf("CORS_ALLOWED_ORIGINS must be set to an explicit, non-wildcard list of origins when APIP_DEMO_MODE=false")
}
return nil
}

Expand Down Expand Up @@ -487,11 +491,20 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server,
// Order: CORS → auth → scope enforcer → mux
var chain []func(http.Handler) http.Handler

// validateAuthConfig already rejected a missing/wildcard allowlist outside demo mode.
// Cross-origin access is disabled by default (empty AllowedOrigins fails closed in the
// CORS middleware); operators must opt in explicitly via CORS_ALLOWED_ORIGINS.
corsOrigins := cfg.CORS.AllowedOrigins
if len(corsOrigins) == 0 {
slogger.Warn("CORS_ALLOWED_ORIGINS not set — cross-origin requests are disabled")
} else if slices.Contains(corsOrigins, "*") {
slogger.Warn("CORS_ALLOWED_ORIGINS contains \"*\" — allowing all origins without credentials")
}
chain = append(chain, gohttpkit.CORSMiddleware(gohttpkit.CORSOptions{
AllowedOrigins: []string{"*"},
AllowedOrigins: corsOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"},
AllowedHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"},
AllowCredentials: true,
AllowCredentials: !slices.Contains(corsOrigins, "*"),
}))

if cfg.Auth.FileBased.Enabled {
Expand Down
Loading