Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-06-11 20:23:35 -04:00
commit 32c195ef2f
6 changed files with 81 additions and 60 deletions

View file

@ -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

View file

@ -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

View file

@ -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"),
})
}

View file

@ -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)

View 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)
}