sec: stop trusting Host header for agent-facing URLs
resolveServerURL: operator-configured REDFLAG_PUBLIC_URL wins; request Host only as fallback with a [WARN]. Applied to install scripts, registration responses, and fleet-join. Host header is attacker-controllable on pre-auth endpoints.
This commit is contained in:
parent
43ddaab262
commit
32c195ef2f
6 changed files with 81 additions and 60 deletions
|
|
@ -8,6 +8,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/api/middleware"
|
||||
"github.com/Fimeg/RedFlag/server/internal/config"
|
||||
"github.com/Fimeg/RedFlag/server/internal/database/queries"
|
||||
"github.com/Fimeg/RedFlag/server/internal/logging"
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
|
|
@ -42,6 +43,7 @@ type AgentHandler struct {
|
|||
signingService *services.SigningService
|
||||
securityLogger *logging.SecurityLogger
|
||||
securitySettings *services.SecuritySettingsService // optional; gates auto-heartbeat
|
||||
config *config.Config
|
||||
checkInInterval int
|
||||
latestAgentVersion string
|
||||
stuckCommandTimeout time.Duration
|
||||
|
|
@ -100,7 +102,7 @@ func (h *AgentHandler) confirmUpdateCommand(agentID uuid.UUID, newVersion string
|
|||
}
|
||||
}
|
||||
|
||||
func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, rtq *queries.RefreshTokenQueries, regTokenQueries *queries.RegistrationTokenQueries, sq *queries.SubsystemQueries, scheduler *scheduler.Scheduler, signingService *services.SigningService, securityLogger *logging.SecurityLogger, checkInInterval int, latestAgentVersion string, stuckCommandTimeout time.Duration, maxCommandRetries int) *AgentHandler {
|
||||
func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, rtq *queries.RefreshTokenQueries, regTokenQueries *queries.RegistrationTokenQueries, sq *queries.SubsystemQueries, scheduler *scheduler.Scheduler, signingService *services.SigningService, securityLogger *logging.SecurityLogger, cfg *config.Config, checkInInterval int, latestAgentVersion string, stuckCommandTimeout time.Duration, maxCommandRetries int) *AgentHandler {
|
||||
if stuckCommandTimeout <= 0 {
|
||||
stuckCommandTimeout = 5 * time.Minute
|
||||
}
|
||||
|
|
@ -116,6 +118,7 @@ func NewAgentHandler(aq *queries.AgentQueries, cq *queries.CommandQueries, rtq *
|
|||
scheduler: scheduler,
|
||||
signingService: signingService,
|
||||
securityLogger: securityLogger,
|
||||
config: cfg,
|
||||
checkInInterval: checkInInterval,
|
||||
latestAgentVersion: latestAgentVersion,
|
||||
stuckCommandTimeout: stuckCommandTimeout,
|
||||
|
|
@ -477,7 +480,7 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
|||
RefreshToken: refreshToken,
|
||||
Config: map[string]interface{}{
|
||||
"check_in_interval": h.checkInInterval,
|
||||
"server_url": c.Request.Host,
|
||||
"server_url": resolveServerURL(c, h.config, "registration"),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -870,7 +873,9 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
|
|||
agent.Metadata["heartbeat_source"] = models.CommandSourceSystem
|
||||
agent.Metadata["rapid_polling_enabled"] = true
|
||||
agent.Metadata["rapid_polling_until"] = rapidPolling.Until
|
||||
_ = h.agentQueries.UpdateAgent(agent)
|
||||
if err := h.agentQueries.UpdateAgent(agent); err != nil {
|
||||
log.Printf("[ERROR] [server] [agents] rapid_polling_metadata_write_failed agent_id=%s error=%v", agentID, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if agent has rapid polling already configured in metadata
|
||||
|
|
|
|||
|
|
@ -44,49 +44,10 @@ func NewDownloadHandler(agentDir string, cfg *config.Config, packageQueries *que
|
|||
}
|
||||
}
|
||||
|
||||
// getServerURL determines the server URL with proper protocol detection
|
||||
// getServerURL determines the server URL with proper protocol detection.
|
||||
// Delegates to resolveServerURL which checks the configured public URL first.
|
||||
func (h *DownloadHandler) getServerURL(c *gin.Context) string {
|
||||
// Priority 1: an explicit operator-configured public URL always wins.
|
||||
if h.config.Server.PublicURL != "" {
|
||||
return h.config.Server.PublicURL
|
||||
}
|
||||
|
||||
// Priority 2: the host the client actually reached us on. This URL gets
|
||||
// baked into the agent's config and every follow-up fetch the install
|
||||
// script makes (binary, manifest, config), so it MUST be a host the agent
|
||||
// can reach — never the server's own bind address (0.0.0.0/localhost). A
|
||||
// remote Windows box that fetched the script from 10.0.0.5:31336 must keep
|
||||
// talking to 10.0.0.5:31336, not localhost:31337. nginx forwards the
|
||||
// original Host ($http_host) + X-Forwarded-Proto, so proxied requests still
|
||||
// resolve to the public host:port.
|
||||
if c != nil && c.Request != nil && c.Request.Host != "" {
|
||||
scheme := "http"
|
||||
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = proto
|
||||
} else if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, c.Request.Host)
|
||||
}
|
||||
|
||||
// Priority 3: fall back to the configured bind address (non-HTTP callers).
|
||||
scheme := "http"
|
||||
host := h.config.Server.Host
|
||||
port := h.config.Server.Port
|
||||
|
||||
if h.config.Server.TLS.Enabled {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
if host == "0.0.0.0" {
|
||||
host = "localhost"
|
||||
}
|
||||
|
||||
if (scheme == "http" && port != 80) || (scheme == "https" && port != 443) {
|
||||
return fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
return resolveServerURL(c, h.config, "downloads")
|
||||
}
|
||||
|
||||
// DownloadPackageArtifact downloads a package from upstream and serves it with SHA256
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/config"
|
||||
"github.com/Fimeg/RedFlag/server/internal/database/queries"
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
"github.com/Fimeg/RedFlag/server/internal/security"
|
||||
|
|
@ -25,15 +26,17 @@ type FleetJoinHandler struct {
|
|||
tokenQueries *queries.RegistrationTokenQueries
|
||||
agentQueries *queries.AgentQueries
|
||||
signingPublicKey string
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// NewFleetJoinHandler creates a FleetJoinHandler.
|
||||
func NewFleetJoinHandler(db *sqlx.DB, tokenQueries *queries.RegistrationTokenQueries, agentQueries *queries.AgentQueries, signingPublicKey string) *FleetJoinHandler {
|
||||
func NewFleetJoinHandler(db *sqlx.DB, tokenQueries *queries.RegistrationTokenQueries, agentQueries *queries.AgentQueries, signingPublicKey string, cfg *config.Config) *FleetJoinHandler {
|
||||
return &FleetJoinHandler{
|
||||
db: db,
|
||||
tokenQueries: tokenQueries,
|
||||
agentQueries: agentQueries,
|
||||
db: db,
|
||||
tokenQueries: tokenQueries,
|
||||
agentQueries: agentQueries,
|
||||
signingPublicKey: signingPublicKey,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -229,6 +232,6 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
|
|||
AgentID: agent.ID,
|
||||
RefreshToken: refreshToken,
|
||||
SigningPublicKey: h.signingPublicKey,
|
||||
ServerURL: c.Request.Host,
|
||||
ServerURL: resolveServerURL(c, h.config, "fleet-join"),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
|
|||
if metadata == nil {
|
||||
metadata = make(map[string]interface{})
|
||||
}
|
||||
metadata["server_url"] = c.Request.Host
|
||||
metadata["server_url"] = resolveServerURL(c, h.config, "registration-tokens")
|
||||
metadata["expires_in"] = expiresIn
|
||||
|
||||
// Default max_seats to 1 if not provided or invalid
|
||||
|
|
@ -128,13 +128,7 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Build install command
|
||||
serverURL := h.config.Server.PublicURL
|
||||
if serverURL == "" {
|
||||
serverURL = "http://" + c.Request.Host
|
||||
if serverURL == "" {
|
||||
serverURL = "http://localhost:8080" // Fallback for development
|
||||
}
|
||||
}
|
||||
serverURL := resolveServerURL(c, h.config, "registration-tokens")
|
||||
// SEC-002: token travels in a header, never the URL — query strings land in
|
||||
// shell history, process lists, and access logs.
|
||||
installCommand := fmt.Sprintf("curl -sfL -H \"X-Registration-Token: %s\" \"%s/api/v1/install/linux\" | sudo bash", token, serverURL)
|
||||
|
|
|
|||
58
server/internal/api/handlers/server_url.go
Normal file
58
server/internal/api/handlers/server_url.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// resolveServerURL returns the canonical external base URL for this server.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. REDFLAG_PUBLIC_URL / cfg.Server.PublicURL — operator-configured; always wins.
|
||||
// 2. c.Request.Host — the host the client reached; baked into agent configs and
|
||||
// install scripts, so it must be reachable from the agent. Logs a structured
|
||||
// warning because trusting an HTTP header is a fallback, not a guarantee.
|
||||
// 3. Bind address — used when c is nil (non-HTTP callers, template pre-render).
|
||||
//
|
||||
// component is the [component] field for ETHOS-format log lines.
|
||||
func resolveServerURL(c *gin.Context, cfg *config.Config, component string) string {
|
||||
// Priority 1: explicit operator config — not attacker-controllable.
|
||||
if cfg != nil && cfg.Server.PublicURL != "" {
|
||||
return cfg.Server.PublicURL
|
||||
}
|
||||
|
||||
// Priority 2: Host header from the live request.
|
||||
// The Host header is attacker-controllable on unauthenticated endpoints; log
|
||||
// a warning so operators know the public URL is unconfigured.
|
||||
if c != nil && c.Request != nil && c.Request.Host != "" {
|
||||
scheme := "http"
|
||||
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = proto
|
||||
} else if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
log.Printf("[WARN] [server] [%s] public_url_unconfigured: trusting request Host header; set REDFLAG_PUBLIC_URL to fix", component)
|
||||
return fmt.Sprintf("%s://%s", scheme, c.Request.Host)
|
||||
}
|
||||
|
||||
// Priority 3: bind address (non-HTTP callers).
|
||||
if cfg == nil {
|
||||
return "http://localhost:8080"
|
||||
}
|
||||
scheme := "http"
|
||||
host := cfg.Server.Host
|
||||
port := cfg.Server.Port
|
||||
if cfg.Server.TLS.Enabled {
|
||||
scheme = "https"
|
||||
}
|
||||
if host == "0.0.0.0" {
|
||||
host = "localhost"
|
||||
}
|
||||
if (scheme == "http" && port != 80) || (scheme == "https" && port != 443) {
|
||||
return fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
Loading…
Reference in a new issue