diff --git a/go-backend/internal/http/handler/federation.go b/go-backend/internal/http/handler/federation.go index 9a06513..402a00f 100644 --- a/go-backend/internal/http/handler/federation.go +++ b/go-backend/internal/http/handler/federation.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "sort" + "strconv" "strings" "sync" "time" @@ -1232,6 +1233,13 @@ func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Reques return } + if isFederationServiceCommand(cmd) { + if err := validateFederationCommandPorts(share, req.Data); err != nil { + response.WriteJSON(w, response.Err(403, err.Error())) + return + } + } + res, err := h.sendNodeCommand(share.NodeID, cmd, req.Data, false, false) if err != nil { response.WriteJSON(w, response.ErrDefault(err.Error())) @@ -1249,6 +1257,55 @@ func isFederationRuntimeCommandAllowed(commandType string) bool { } } +func isFederationServiceCommand(commandType string) bool { + switch strings.ToLower(strings.TrimSpace(commandType)) { + case "addservice", "updateservice": + return true + default: + return false + } +} + +func validateFederationCommandPorts(share *sqlite.PeerShare, data interface{}) error { + if share == nil || (share.PortRangeStart <= 0 && share.PortRangeEnd <= 0) { + return nil + } + dataMap, ok := data.(map[string]interface{}) + if !ok { + return nil + } + services, ok := dataMap["services"] + if !ok { + return nil + } + serviceList, ok := services.([]interface{}) + if !ok { + return nil + } + for _, svc := range serviceList { + svcMap, ok := svc.(map[string]interface{}) + if !ok { + continue + } + addr, ok := svcMap["addr"].(string) + if !ok || addr == "" { + continue + } + _, portStr, err := net.SplitHostPort(addr) + if err != nil { + continue + } + port, err := strconv.Atoi(portStr) + if err != nil || port <= 0 { + continue + } + if port < share.PortRangeStart || port > share.PortRangeEnd { + return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd) + } + } + return nil +} + func (h *Handler) pickPeerSharePort(share *sqlite.PeerShare, requestedPort int) (int, error) { if share == nil { return 0, fmt.Errorf("share not found") diff --git a/go-backend/tests/contract/federation_dual_panel_contract_test.go b/go-backend/tests/contract/federation_dual_panel_contract_test.go index 784ad29..f5e3d49 100644 --- a/go-backend/tests/contract/federation_dual_panel_contract_test.go +++ b/go-backend/tests/contract/federation_dual_panel_contract_test.go @@ -688,3 +688,112 @@ func valueAsBool(v interface{}) bool { return false } } + +func TestFederationRuntimeCommandPortRangeEnforcement(t *testing.T) { + providerSecret := "provider-portrange-jwt" + providerRouter, providerRepo := setupContractRouter(t, providerSecret) + providerServer := httptest.NewServer(providerRouter) + defer providerServer.Close() + + now := time.Now().UnixMilli() + providerNodeID := insertContractNode(t, providerRepo, "provider-portrange-node", "198.51.100.50", "44000-44010", "provider-portrange-secret", 1) + + insertPeerShare(t, providerRepo, &sqlite.PeerShare{ + Name: "portrange-share", + NodeID: providerNodeID, + Token: "share-portrange-token", + PortRangeStart: 44000, + PortRangeEnd: 44010, + IsActive: 1, + CreatedTime: now, + UpdatedTime: now, + }) + + stopNode := startMockNodeSession(t, providerServer.URL, "provider-portrange-secret") + defer stopNode() + + sendCommand := func(token string, cmdType string, data interface{}) *httptest.ResponseRecorder { + payload := map[string]interface{}{ + "commandType": cmdType, + "data": data, + } + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal command payload: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/command", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + providerRouter.ServeHTTP(res, req) + return res + } + + // Test: AddService with port OUTSIDE allowed range should be rejected + outOfRangeData := map[string]interface{}{ + "services": []map[string]interface{}{ + { + "name": "test_service_tcp", + "addr": "[::]:55555", + "handler": map[string]interface{}{ + "type": "tcp", + }, + "listener": map[string]interface{}{ + "type": "tcp", + }, + }, + }, + } + res := sendCommand("share-portrange-token", "AddService", outOfRangeData) + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 403 { + t.Fatalf("expected code 403 for out-of-range port, got %d (msg: %s)", out.Code, out.Msg) + } + + // Test: UpdateService with port OUTSIDE allowed range should be rejected + res = sendCommand("share-portrange-token", "UpdateService", outOfRangeData) + out = response.R{} + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 403 { + t.Fatalf("expected code 403 for out-of-range UpdateService, got %d (msg: %s)", out.Code, out.Msg) + } + + // Test: AddService with port INSIDE allowed range should succeed + inRangeData := map[string]interface{}{ + "services": []map[string]interface{}{ + { + "name": "test_service_ok_tcp", + "addr": "[::]:44005", + "handler": map[string]interface{}{ + "type": "tcp", + }, + "listener": map[string]interface{}{ + "type": "tcp", + }, + }, + }, + } + res = sendCommand("share-portrange-token", "AddService", inRangeData) + out = response.R{} + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0 for in-range port, got %d (msg: %s)", out.Code, out.Msg) + } + + // Test: Non-service commands should pass through without port validation + res = sendCommand("share-portrange-token", "reload", nil) + out = response.R{} + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0 for reload command, got %d (msg: %s)", out.Code, out.Msg) + } +}