fix: registration token moves from install URL to X-Registration-Token header (SEC-002)
A token in the query string leaks to shell history, process lists, and server access logs. The install endpoint now reads X-Registration-Token; a query-string token is refused with guidance and is never echoed back or logged. Server-built and web-UI install commands updated (curl -H, irm -Headers).
This commit is contained in:
parent
0abff08a9d
commit
1c4b363375
4 changed files with 57 additions and 7 deletions
|
|
@ -847,10 +847,20 @@ func (h *DownloadHandler) generateInstallScript(c *gin.Context, platform, baseUR
|
|||
// Parse agent ID with defense-in-depth priority
|
||||
agentIDParam := parseAgentID(c)
|
||||
|
||||
// Extract registration token from query parameters
|
||||
registrationToken := c.Query("token")
|
||||
// Extract registration token from header. Query-string tokens are rejected
|
||||
// (SEC-002): a token in the URL leaks to process lists, shell history, and
|
||||
// server access logs. The token value is never echoed back.
|
||||
registrationToken := c.GetHeader("X-Registration-Token")
|
||||
if registrationToken == "" {
|
||||
return "# Error: registration token is required\n# Please include token in URL: ?token=YOUR_TOKEN\n"
|
||||
if c.Query("token") != "" {
|
||||
log.Printf("[WARNING] [server] [downloads] install_token_in_query_rejected remote=%s — token passed via URL, refusing (SEC-002)", c.ClientIP())
|
||||
return "# Error: passing the registration token in the URL is not supported (it leaks to logs and process lists).\n" +
|
||||
"# Use the header form instead:\n" +
|
||||
"# curl -sfL -H \"X-Registration-Token: YOUR_TOKEN\" \"<server>/api/v1/install/<platform>\" | sudo bash\n"
|
||||
}
|
||||
return "# Error: registration token is required\n" +
|
||||
"# Pass it via header:\n" +
|
||||
"# curl -sfL -H \"X-Registration-Token: YOUR_TOKEN\" \"<server>/api/v1/install/<platform>\" | sudo bash\n"
|
||||
}
|
||||
|
||||
// Determine architecture: use ?arch= query param or default to amd64
|
||||
|
|
|
|||
|
|
@ -15,6 +15,41 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestInstallScriptRejectsQueryToken locks in SEC-002: a registration token
|
||||
// passed as a URL query parameter must be refused (it leaks to shell history,
|
||||
// process lists, and access logs) and must never be echoed back.
|
||||
func TestInstallScriptRejectsQueryToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate signing key: %v", err)
|
||||
}
|
||||
signingService, err := services.NewSigningService(hex.EncodeToString(privateKey))
|
||||
if err != nil {
|
||||
t.Fatalf("new signing service: %v", err)
|
||||
}
|
||||
|
||||
handler := NewDownloadHandler("/tmp/redflag-test", &config.Config{}, nil, signingService)
|
||||
router := gin.New()
|
||||
router.GET("/api/v1/install/:platform", handler.InstallScript)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/install/linux?token=leaked-secret-token", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
body := resp.Body.String()
|
||||
if strings.Contains(body, "leaked-secret-token") {
|
||||
t.Fatalf("rejected install script echoes the token back:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "X-Registration-Token") {
|
||||
t.Fatalf("rejection message does not point at the header form:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "#!/") || strings.Contains(body, "Registering agent") {
|
||||
t.Fatalf("query-token request still rendered a real install script")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsInstallScriptUsesResolvedVersionAndRuntimeServerURL(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
|
@ -31,7 +66,8 @@ func TestWindowsInstallScriptUsesResolvedVersionAndRuntimeServerURL(t *testing.T
|
|||
router := gin.New()
|
||||
router.GET("/api/v1/install/:platform", handler.InstallScript)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/install/windows?token=test-token", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/install/windows", nil)
|
||||
req.Header.Set("X-Registration-Token", "test-token")
|
||||
req.Host = "redflag.example:31336"
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
|
|
|||
|
|
@ -110,7 +110,9 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
|
|||
serverURL = "http://localhost:8080" // Fallback for development
|
||||
}
|
||||
}
|
||||
installCommand := fmt.Sprintf("curl -sfL \"%s/api/v1/install/linux?token=%s\" | sudo bash", serverURL, token)
|
||||
// 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)
|
||||
|
||||
response := gin.H{
|
||||
"token": token,
|
||||
|
|
|
|||
Loading…
Reference in a new issue