mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-15 09:49:48 +00:00
Canonicalize loopback-only Pulse transport validation
This commit is contained in:
parent
4720807ae5
commit
d64f5b2917
13 changed files with 312 additions and 275 deletions
|
|
@ -1108,12 +1108,16 @@ fan-out writes a bootstrap token or runs the installer on a remote node, keep
|
|||
`StrictHostKeyChecking=yes`, and fail closed on key mismatch or missing-host-
|
||||
key state instead of downgrading to unauthenticated SSH during install.
|
||||
That same transport boundary also keeps plaintext Pulse URLs loopback-only.
|
||||
`internal/hostagent/agent.go`, `internal/hostagent/commands.go`, and
|
||||
`internal/agentupdate/update.go` may keep local-development `http://` or
|
||||
`ws://` only for loopback hosts, but private-network and remote Pulse URLs
|
||||
must still use HTTPS/WSS. `InsecureSkipVerify` may relax certificate
|
||||
verification on TLS transport; it must not reopen plaintext HTTP for
|
||||
private-network updater, websocket, or reporting paths.
|
||||
`internal/securityutil/httpurl.go` owns the canonical Pulse transport
|
||||
normalization used by `internal/hostagent/agent.go`,
|
||||
`internal/hostagent/commands.go`, `internal/agentupdate/update.go`,
|
||||
`internal/dockeragent/agent.go`, `internal/kubernetesagent/agent.go`, and
|
||||
`internal/remoteconfig/client.go`. Those runtime clients may keep local-
|
||||
development `http://` or `ws://` only for loopback hosts, but private-network
|
||||
and remote Pulse URLs must still use HTTPS/WSS. `InsecureSkipVerify` may
|
||||
relax certificate verification on TLS transport; it must not reopen plaintext
|
||||
HTTP for private-network updater, websocket, reporting, or remote-config
|
||||
paths.
|
||||
That same first-run lifecycle boundary also keeps unauthenticated setup local.
|
||||
Lifecycle-adjacent quick setup or recovery entrypoints may exist before an
|
||||
operator has configured auth, but they must stay direct-loopback only and any
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import (
|
|||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
||||
)
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
|
@ -174,7 +176,7 @@ func TestIsLoopbackHost(t *testing.T) {
|
|||
t.Run("true", func(t *testing.T) {
|
||||
cases := []string{"localhost", "LOCALHOST", "agent.localhost", "127.0.0.1", "::1"}
|
||||
for _, tc := range cases {
|
||||
if !isLoopbackHost(tc) {
|
||||
if !securityutil.IsLoopbackHost(tc) {
|
||||
t.Fatalf("expected loopback host for %q", tc)
|
||||
}
|
||||
}
|
||||
|
|
@ -183,7 +185,7 @@ func TestIsLoopbackHost(t *testing.T) {
|
|||
t.Run("false", func(t *testing.T) {
|
||||
cases := []string{"", "example.com", "192.168.1.10", "10.0.0.5"}
|
||||
for _, tc := range cases {
|
||||
if isLoopbackHost(tc) {
|
||||
if securityutil.IsLoopbackHost(tc) {
|
||||
t.Fatalf("expected non-loopback host for %q", tc)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -23,6 +21,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agenttls"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/updatesignature"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
"github.com/rs/zerolog"
|
||||
|
|
@ -559,52 +558,19 @@ func normalizeAgentName(agentName string) (string, error) {
|
|||
return name, nil
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
normalized := strings.ToLower(strings.Trim(host, "[]"))
|
||||
if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") {
|
||||
return true
|
||||
}
|
||||
|
||||
ip := net.ParseIP(normalized)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func (u *Updater) validatePulseURL() error {
|
||||
pulseURL := strings.TrimSpace(u.cfg.PulseURL)
|
||||
if pulseURL == "" {
|
||||
return fmt.Errorf("Pulse URL is empty")
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(pulseURL)
|
||||
parsed, err := securityutil.NormalizePulseHTTPBaseURL(pulseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse Pulse URL: %w", err)
|
||||
}
|
||||
if parsed.Scheme == "" || parsed.Host == "" {
|
||||
return fmt.Errorf("Pulse URL must include scheme and host")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return fmt.Errorf("Pulse URL must not include user credentials")
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return fmt.Errorf("Pulse URL must not include query or fragment")
|
||||
return fmt.Errorf("invalid Pulse URL: %w", err)
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
switch scheme {
|
||||
case "https":
|
||||
return nil
|
||||
case "http":
|
||||
if isLoopbackHost(parsed.Hostname()) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("HTTP Pulse URL is only allowed for localhost/loopback")
|
||||
default:
|
||||
return fmt.Errorf("unsupported Pulse URL scheme %q", parsed.Scheme)
|
||||
}
|
||||
u.cfg.PulseURL = parsed.String()
|
||||
return nil
|
||||
}
|
||||
|
||||
// performUpdate downloads and installs the new agent binary.
|
||||
|
|
|
|||
|
|
@ -7,18 +7,16 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
systemtypes "github.com/moby/moby/api/types/system"
|
||||
"github.com/moby/moby/client"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
|
||||
"github.com/rs/zerolog"
|
||||
|
|
@ -340,60 +338,11 @@ func normalizeTargets(raw []TargetConfig) ([]TargetConfig, error) {
|
|||
}
|
||||
|
||||
func normalizeTargetURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
parsed, err := securityutil.NormalizePulseHTTPBaseURL(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", errors.New("must include http:// or https:// with a valid host")
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return "", fmt.Errorf("unsupported scheme %q", parsed.Scheme)
|
||||
}
|
||||
|
||||
if scheme == "http" {
|
||||
host := parsed.Hostname()
|
||||
ip := net.ParseIP(host)
|
||||
isLoopbackOrPrivate := strings.EqualFold(host, "localhost") ||
|
||||
(ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()))
|
||||
if !isLoopbackOrPrivate {
|
||||
return "", fmt.Errorf("http is only allowed for loopback or private network addresses, use https for %s", host)
|
||||
}
|
||||
}
|
||||
|
||||
if parsed.User != nil {
|
||||
return "", errors.New("userinfo is not supported")
|
||||
}
|
||||
|
||||
if parsed.RawQuery != "" {
|
||||
return "", errors.New("query parameters are not supported")
|
||||
}
|
||||
|
||||
if parsed.Fragment != "" {
|
||||
return "", errors.New("fragments are not supported")
|
||||
}
|
||||
|
||||
if parsed.Hostname() == "" {
|
||||
return "", errors.New("host is required")
|
||||
}
|
||||
|
||||
if port := parsed.Port(); port != "" {
|
||||
portNum, err := strconv.Atoi(port)
|
||||
if err != nil || portNum < 1 || portNum > 65535 {
|
||||
return "", fmt.Errorf("invalid port %q: must be between 1 and 65535", port)
|
||||
}
|
||||
}
|
||||
|
||||
parsed.Scheme = scheme
|
||||
parsed.Host = strings.ToLower(parsed.Host)
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
parsed.RawPath = ""
|
||||
|
||||
normalized := strings.TrimRight(parsed.String(), "/")
|
||||
if normalized == "" {
|
||||
return "", errors.New("URL is empty after normalization")
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ func TestNormalizeTargetsRejectsInsecureOrInvalidURLs(t *testing.T) {
|
|||
url string
|
||||
}{
|
||||
{name: "non-loopback http", url: "http://pulse.example.com"},
|
||||
{name: "private-network http", url: "http://10.0.0.5:7655"},
|
||||
{name: "unsupported scheme", url: "ftp://pulse.example.com"},
|
||||
{name: "missing scheme", url: "pulse.example.com"},
|
||||
{name: "query string", url: "https://pulse.example.com?x=1"},
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import (
|
|||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -22,6 +21,7 @@ import (
|
|||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agenttls"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentupdate"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/sensors"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
||||
|
|
@ -845,58 +845,14 @@ func normalisePlatform(platform string) string {
|
|||
}
|
||||
|
||||
func normalizePulseURL(rawURL string) (string, error) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
parsed, err := securityutil.NormalizePulseHTTPBaseURL(rawURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pulse URL %q is invalid: %w", rawURL, err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
if parsed.Scheme == "" {
|
||||
return "", fmt.Errorf("pulse URL %q must include scheme (https:// or loopback http://)", rawURL)
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return "", fmt.Errorf("pulse URL %q must include host", rawURL)
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return "", fmt.Errorf("pulse URL %q must not include user credentials", rawURL)
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", fmt.Errorf("pulse URL %q must not include query or fragment", rawURL)
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
switch scheme {
|
||||
case "https":
|
||||
// Always allowed.
|
||||
case "http":
|
||||
if !isLoopbackHost(parsed.Hostname()) {
|
||||
return "", fmt.Errorf("pulse URL %q must use https unless host is loopback", rawURL)
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("pulse URL %q has unsupported scheme %q", rawURL, parsed.Scheme)
|
||||
}
|
||||
|
||||
parsed.Scheme = scheme
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
parsed.RawPath = strings.TrimRight(parsed.RawPath, "/")
|
||||
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// isLoopbackHost returns true for localhost and loopback IPs only. Plain HTTP
|
||||
// is never allowed for non-loopback Pulse URLs, even on private networks.
|
||||
func isLoopbackHost(host string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(host, "[]"))
|
||||
if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") {
|
||||
return true
|
||||
}
|
||||
|
||||
ip := net.ParseIP(normalized)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
|
||||
// collectTemperatures attempts to collect temperature data from the local system.
|
||||
// Returns an empty Sensors struct if collection fails (best-effort).
|
||||
func (a *Agent) collectTemperatures(ctx context.Context) agentshost.Sensors {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -21,6 +20,7 @@ import (
|
|||
"github.com/gorilla/websocket"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agenttls"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
||||
sshknownhosts "github.com/rcourtman/pulse-go-rewrite/internal/ssh/knownhosts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
"github.com/rs/zerolog"
|
||||
|
|
@ -351,44 +351,9 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error {
|
|||
}
|
||||
|
||||
func (c *CommandClient) buildWebSocketURL() (string, error) {
|
||||
parsed, err := url.Parse(c.pulseURL)
|
||||
parsed, err := securityutil.NormalizePulseWebSocketBaseURL(c.pulseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pulse URL is invalid: %w", err)
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return "", fmt.Errorf("missing host in pulse URL")
|
||||
}
|
||||
|
||||
if parsed.Scheme == "" {
|
||||
return "", fmt.Errorf("pulse URL %q must include scheme", c.pulseURL)
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return "", fmt.Errorf("pulse URL %q must include host", c.pulseURL)
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return "", fmt.Errorf("pulse URL %q must not include user credentials", c.pulseURL)
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", fmt.Errorf("pulse URL %q must not include query or fragment", c.pulseURL)
|
||||
}
|
||||
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "https":
|
||||
parsed.Scheme = "wss"
|
||||
case "http":
|
||||
if !isLoopbackHost(parsed.Hostname()) {
|
||||
return "", fmt.Errorf("pulse URL %q must use https/wss unless host is loopback", c.pulseURL)
|
||||
}
|
||||
parsed.Scheme = "ws"
|
||||
case "wss":
|
||||
parsed.Scheme = "wss"
|
||||
case "ws":
|
||||
if !isLoopbackHost(parsed.Hostname()) {
|
||||
return "", fmt.Errorf("pulse URL %q must use https/wss unless host is loopback", c.pulseURL)
|
||||
}
|
||||
parsed.Scheme = "ws"
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported URL scheme %q", parsed.Scheme)
|
||||
return "", err
|
||||
}
|
||||
|
||||
basePath := strings.TrimRight(parsed.Path, "/")
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ import (
|
|||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/IGLOU-EU/go-wildcard/v2"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
agentsk8s "github.com/rcourtman/pulse-go-rewrite/pkg/agents/kubernetes"
|
||||
"github.com/rs/zerolog"
|
||||
|
|
@ -223,69 +223,14 @@ func New(cfg Config) (*Agent, error) {
|
|||
}
|
||||
|
||||
func normalizePulseURL(rawURL string) (string, error) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
parsed, err := securityutil.NormalizePulseHTTPBaseURL(rawURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pulse URL %q is invalid: %w", rawURL, err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
if parsed.Scheme == "" {
|
||||
return "", fmt.Errorf("pulse URL %q must include http:// or https:// scheme", rawURL)
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return "", fmt.Errorf("pulse URL %q must include host", rawURL)
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return "", fmt.Errorf("pulse URL %q: userinfo is not supported", rawURL)
|
||||
}
|
||||
if parsed.RawQuery != "" {
|
||||
return "", fmt.Errorf("pulse URL %q: query parameters are not supported", rawURL)
|
||||
}
|
||||
if parsed.Fragment != "" {
|
||||
return "", fmt.Errorf("pulse URL %q: fragments are not supported", rawURL)
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
switch scheme {
|
||||
case "https":
|
||||
// Always allowed.
|
||||
case "http":
|
||||
if !isLoopbackOrPrivateHost(parsed.Hostname()) {
|
||||
return "", fmt.Errorf("pulse URL %q must use https unless host is loopback or private network", rawURL)
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("pulse URL %q has unsupported scheme %q", rawURL, parsed.Scheme)
|
||||
}
|
||||
|
||||
if port := parsed.Port(); port != "" {
|
||||
portNum, err := strconv.Atoi(port)
|
||||
if err != nil || portNum < 1 || portNum > 65535 {
|
||||
return "", fmt.Errorf("invalid port %q: must be between 1 and 65535", port)
|
||||
}
|
||||
}
|
||||
|
||||
parsed.Scheme = scheme
|
||||
parsed.Host = strings.ToLower(parsed.Host)
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
parsed.RawPath = strings.TrimRight(parsed.RawPath, "/")
|
||||
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// isLoopbackOrPrivateHost returns true for loopback and RFC1918 private
|
||||
// network addresses. HTTP (non-TLS) is safe over a local/private network;
|
||||
// the scheme guard only needs to prevent plaintext over the public internet.
|
||||
func isLoopbackOrPrivateHost(host string) bool {
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()
|
||||
}
|
||||
|
||||
func buildRESTConfig(kubeconfigPath, kubeContext string) (*rest.Config, string, error) {
|
||||
kubeconfigPath = strings.TrimSpace(kubeconfigPath)
|
||||
kubeContext = strings.TrimSpace(kubeContext)
|
||||
|
|
|
|||
|
|
@ -123,10 +123,15 @@ func TestNormalizePulseURL(t *testing.T) {
|
|||
raw: "http://localhost:7655/",
|
||||
want: "http://localhost:7655",
|
||||
},
|
||||
{
|
||||
name: "rejects private-network http",
|
||||
raw: "http://10.0.0.5:7655",
|
||||
wantError: "must use https unless host is loopback",
|
||||
},
|
||||
{
|
||||
name: "missing scheme",
|
||||
raw: "pulse.example.com",
|
||||
wantError: "must include http:// or https://",
|
||||
wantError: "must include scheme",
|
||||
},
|
||||
{
|
||||
name: "unsupported scheme",
|
||||
|
|
@ -141,17 +146,17 @@ func TestNormalizePulseURL(t *testing.T) {
|
|||
{
|
||||
name: "userinfo disallowed",
|
||||
raw: "https://user:pass@pulse.example.com",
|
||||
wantError: "userinfo is not supported",
|
||||
wantError: "must not include user credentials",
|
||||
},
|
||||
{
|
||||
name: "query disallowed",
|
||||
raw: "https://pulse.example.com?x=1",
|
||||
wantError: "query parameters are not supported",
|
||||
wantError: "must not include query or fragment",
|
||||
},
|
||||
{
|
||||
name: "fragment disallowed",
|
||||
raw: "https://pulse.example.com#frag",
|
||||
wantError: "fragments are not supported",
|
||||
wantError: "must not include query or fragment",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,17 +3,16 @@ package remoteconfig
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agenttls"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
|
@ -367,33 +366,9 @@ func normalizeConfig(cfg Config) (Config, error) {
|
|||
}
|
||||
|
||||
func normalizePulseURL(raw string) (string, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
parsed, err := securityutil.NormalizePulseHTTPBaseURL(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid pulse URL: %w", err)
|
||||
}
|
||||
|
||||
switch parsed.Scheme {
|
||||
case "http", "https":
|
||||
default:
|
||||
return "", fmt.Errorf("invalid pulse URL scheme %q: must be http or https", parsed.Scheme)
|
||||
}
|
||||
|
||||
if parsed.Hostname() == "" {
|
||||
return "", errors.New("invalid pulse URL: missing host")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return "", errors.New("invalid pulse URL: userinfo is not allowed")
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", errors.New("invalid pulse URL: query and fragment are not allowed")
|
||||
}
|
||||
|
||||
if port := parsed.Port(); port != "" {
|
||||
portValue, err := strconv.Atoi(port)
|
||||
if err != nil || portValue < 1 || portValue > 65535 {
|
||||
return "", fmt.Errorf("invalid pulse URL port %q: must be between 1 and 65535", port)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimRight(parsed.String(), "/"), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -284,7 +284,7 @@ func TestClientResolveAgentIDRequestErrors(t *testing.T) {
|
|||
}
|
||||
|
||||
client = New(Config{
|
||||
PulseURL: "http://example.com",
|
||||
PulseURL: "https://example.com",
|
||||
APIToken: "t",
|
||||
Hostname: "host",
|
||||
})
|
||||
|
|
@ -309,6 +309,15 @@ func TestClientFetchConfigValidation(t *testing.T) {
|
|||
},
|
||||
wantText: "invalid remote config client configuration",
|
||||
},
|
||||
{
|
||||
name: "private-network http rejected",
|
||||
cfg: Config{
|
||||
PulseURL: "http://10.0.0.5:7655",
|
||||
APIToken: "token",
|
||||
AgentID: "agent-1",
|
||||
},
|
||||
wantText: "must use https unless host is loopback",
|
||||
},
|
||||
{
|
||||
name: "missing API token",
|
||||
cfg: Config{
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
|
@ -98,6 +100,122 @@ func NormalizeHTTPBaseURL(raw string, defaultScheme string) (*url.URL, error) {
|
|||
return parsed, nil
|
||||
}
|
||||
|
||||
// IsLoopbackHost reports whether host resolves to localhost or a loopback IP literal.
|
||||
func IsLoopbackHost(host string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(host, "[]"))
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") {
|
||||
return true
|
||||
}
|
||||
|
||||
ip := net.ParseIP(normalized)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// NormalizePulseHTTPBaseURL validates a Pulse control-plane base URL.
|
||||
// HTTPS is required for non-loopback hosts; loopback localhost may use HTTP.
|
||||
func NormalizePulseHTTPBaseURL(raw string) (*url.URL, error) {
|
||||
parsed, err := normalizePulseBaseURL(raw, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// NormalizePulseWebSocketBaseURL validates a Pulse command-channel base URL.
|
||||
// Non-loopback hosts are normalized to WSS; loopback localhost may use WS.
|
||||
func NormalizePulseWebSocketBaseURL(raw string) (*url.URL, error) {
|
||||
parsed, err := normalizePulseBaseURL(raw, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func normalizePulseBaseURL(raw string, websocket bool) (*url.URL, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("Pulse URL is required")
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Pulse URL %q is invalid: %w", raw, err)
|
||||
}
|
||||
if parsed.Scheme == "" {
|
||||
if websocket {
|
||||
return nil, fmt.Errorf("Pulse URL %q must include scheme (https://, wss://, or loopback http:// / ws://)", raw)
|
||||
}
|
||||
return nil, fmt.Errorf("Pulse URL %q must include scheme (https:// or loopback http://)", raw)
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return nil, fmt.Errorf("Pulse URL %q must include host", raw)
|
||||
}
|
||||
if parsed.Hostname() == "" {
|
||||
return nil, fmt.Errorf("Pulse URL %q must include host", raw)
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return nil, fmt.Errorf("Pulse URL %q must not include user credentials", raw)
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, fmt.Errorf("Pulse URL %q must not include query or fragment", raw)
|
||||
}
|
||||
|
||||
if port := parsed.Port(); port != "" {
|
||||
portNum, err := strconv.Atoi(port)
|
||||
if err != nil || portNum < 1 || portNum > 65535 {
|
||||
return nil, fmt.Errorf("invalid port %q: must be between 1 and 65535", port)
|
||||
}
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
switch scheme {
|
||||
case "https":
|
||||
if websocket {
|
||||
parsed.Scheme = "wss"
|
||||
} else {
|
||||
parsed.Scheme = "https"
|
||||
}
|
||||
case "http":
|
||||
if !IsLoopbackHost(parsed.Hostname()) {
|
||||
if websocket {
|
||||
return nil, fmt.Errorf("Pulse URL %q must use https/wss unless host is loopback", raw)
|
||||
}
|
||||
return nil, fmt.Errorf("Pulse URL %q must use https unless host is loopback", raw)
|
||||
}
|
||||
if websocket {
|
||||
parsed.Scheme = "ws"
|
||||
} else {
|
||||
parsed.Scheme = "http"
|
||||
}
|
||||
case "wss":
|
||||
if !websocket {
|
||||
return nil, fmt.Errorf("Pulse URL %q has unsupported scheme %q", raw, parsed.Scheme)
|
||||
}
|
||||
parsed.Scheme = "wss"
|
||||
case "ws":
|
||||
if !websocket {
|
||||
return nil, fmt.Errorf("Pulse URL %q has unsupported scheme %q", raw, parsed.Scheme)
|
||||
}
|
||||
if !IsLoopbackHost(parsed.Hostname()) {
|
||||
return nil, fmt.Errorf("Pulse URL %q must use https/wss unless host is loopback", raw)
|
||||
}
|
||||
parsed.Scheme = "ws"
|
||||
default:
|
||||
return nil, fmt.Errorf("Pulse URL %q has unsupported scheme %q", raw, parsed.Scheme)
|
||||
}
|
||||
|
||||
parsed.Host = strings.ToLower(parsed.Host)
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
parsed.RawPath = strings.TrimRight(parsed.RawPath, "/")
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// AppendURLPath appends path segments onto a validated base URL.
|
||||
func AppendURLPath(base *url.URL, segments ...string) *url.URL {
|
||||
cloned := cloneURL(base)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,148 @@ func TestNormalizeHTTPBaseURLRejectsQuery(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestIsLoopbackHost(t *testing.T) {
|
||||
t.Run("true", func(t *testing.T) {
|
||||
cases := []string{"localhost", "LOCALHOST", "agent.localhost", "127.0.0.1", "::1"}
|
||||
for _, tc := range cases {
|
||||
if !IsLoopbackHost(tc) {
|
||||
t.Fatalf("expected loopback host for %q", tc)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("false", func(t *testing.T) {
|
||||
cases := []string{"", "example.com", "192.168.1.10", "10.0.0.5"}
|
||||
for _, tc := range cases {
|
||||
if IsLoopbackHost(tc) {
|
||||
t.Fatalf("expected non-loopback host for %q", tc)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizePulseHTTPBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "normalizes secure url",
|
||||
raw: "HTTPS://Pulse.Example.com:7655/base/",
|
||||
want: "https://pulse.example.com:7655/base",
|
||||
},
|
||||
{
|
||||
name: "allows loopback http",
|
||||
raw: "http://LOCALHOST:7655/",
|
||||
want: "http://localhost:7655",
|
||||
},
|
||||
{
|
||||
name: "rejects private-network http",
|
||||
raw: "http://10.0.0.5:7655",
|
||||
wantError: "must use https unless host is loopback",
|
||||
},
|
||||
{
|
||||
name: "rejects unsupported scheme",
|
||||
raw: "ftp://pulse.example.com",
|
||||
wantError: "unsupported scheme",
|
||||
},
|
||||
{
|
||||
name: "rejects query",
|
||||
raw: "https://pulse.example.com?x=1",
|
||||
wantError: "must not include query or fragment",
|
||||
},
|
||||
{
|
||||
name: "rejects bad port",
|
||||
raw: "https://pulse.example.com:70000",
|
||||
wantError: "invalid port",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NormalizePulseHTTPBaseURL(tt.raw)
|
||||
if tt.wantError != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("NormalizePulseHTTPBaseURL(%q) expected error", tt.raw)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantError) {
|
||||
t.Fatalf("NormalizePulseHTTPBaseURL(%q) error = %q, want substring %q", tt.raw, err.Error(), tt.wantError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizePulseHTTPBaseURL(%q) error = %v", tt.raw, err)
|
||||
}
|
||||
if got.String() != tt.want {
|
||||
t.Fatalf("NormalizePulseHTTPBaseURL(%q) = %q, want %q", tt.raw, got.String(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePulseWebSocketBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "https becomes wss",
|
||||
raw: "https://example.invalid/pulse/",
|
||||
want: "wss://example.invalid/pulse",
|
||||
},
|
||||
{
|
||||
name: "loopback http becomes ws",
|
||||
raw: "http://localhost:7655",
|
||||
want: "ws://localhost:7655",
|
||||
},
|
||||
{
|
||||
name: "wss preserved",
|
||||
raw: "wss://example.invalid",
|
||||
want: "wss://example.invalid",
|
||||
},
|
||||
{
|
||||
name: "non-loopback http rejected",
|
||||
raw: "http://example.invalid",
|
||||
wantError: "must use https/wss unless host is loopback",
|
||||
},
|
||||
{
|
||||
name: "private-network ws rejected",
|
||||
raw: "ws://10.0.0.5:7655",
|
||||
wantError: "must use https/wss unless host is loopback",
|
||||
},
|
||||
{
|
||||
name: "unsupported scheme rejected",
|
||||
raw: "ftp://example.invalid",
|
||||
wantError: "unsupported scheme",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NormalizePulseWebSocketBaseURL(tt.raw)
|
||||
if tt.wantError != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("NormalizePulseWebSocketBaseURL(%q) expected error", tt.raw)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantError) {
|
||||
t.Fatalf("NormalizePulseWebSocketBaseURL(%q) error = %q, want substring %q", tt.raw, err.Error(), tt.wantError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizePulseWebSocketBaseURL(%q) error = %v", tt.raw, err)
|
||||
}
|
||||
if got.String() != tt.want {
|
||||
t.Fatalf("NormalizePulseWebSocketBaseURL(%q) = %q, want %q", tt.raw, got.String(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRelativeURLRejectsAbsoluteURL(t *testing.T) {
|
||||
base, err := NormalizeHTTPBaseURL("https://example.com/api", "")
|
||||
if err != nil {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue