From 5ffd896a7ce6bbcfc2dd5e22133d6116ce7824ac Mon Sep 17 00:00:00 2001 From: farhadh Date: Mon, 11 May 2026 21:10:33 +0200 Subject: [PATCH] fix(security): SSRF prevention, trusted-proxy header gating, CSP nonce, HTTP timeouts Adds SanitizeHTTPURL / SanitizePublicHTTPURL to reject private-range and loopback targets before any outbound HTTP request (node probe, xray download, outbound test, external traffic inform, tgbot API server, panel updater). Forwarded headers (X-Real-IP, X-Forwarded-For, X-Forwarded-Host) are now only trusted when the direct connection arrives from a CIDR in TrustedProxyCIDRs. CSP policy is tightened with a per-request nonce. HTTP server gains read/write/idle timeouts. Panel updater downloads the script to a temp file instead of piping curl into shell. Xray archive download adds a size cap and response-code check. backuptotgbot is changed from GET to POST. --- web/controller/api.go | 2 +- web/controller/inbound.go | 20 +++--- web/controller/util.go | 64 ++++++++++++++++--- web/controller/util_test.go | 34 ++++++++++ web/controller/xray_setting.go | 5 ++ web/job/xray_traffic_job.go | 5 ++ web/middleware/security.go | 14 ++++- web/service/node.go | 27 +++++--- web/service/panel.go | 53 +++++++++++++++- web/service/server.go | 82 +++++++++++++++++++++---- web/service/setting_security_test.go | 92 ++++++++++++++++++++++++++++ web/service/tgbot.go | 11 ++-- web/service/url_safety.go | 82 +++++++++++++++++++++++++ web/web.go | 6 +- 14 files changed, 444 insertions(+), 53 deletions(-) create mode 100644 web/controller/util_test.go create mode 100644 web/service/setting_security_test.go create mode 100644 web/service/url_safety.go diff --git a/web/controller/api.go b/web/controller/api.go index 219632d5..8aaeaefa 100644 --- a/web/controller/api.go +++ b/web/controller/api.go @@ -85,7 +85,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom NewCustomGeoController(api.Group("/custom-geo"), customGeo) // Extra routes - api.GET("/backuptotgbot", a.BackuptoTgbot) + api.POST("/backuptotgbot", a.BackuptoTgbot) } // BackuptoTgbot sends a backup of the panel data to Telegram bot admins. diff --git a/web/controller/inbound.go b/web/controller/inbound.go index 79f5d4eb..99c2b69a 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -582,17 +582,19 @@ func (a *InboundController) delInboundClientByEmail(c *gin.Context) { // controller layer means the service interface stays HTTP-agnostic — service // methods receive a plain host string instead of a *gin.Context. func resolveHost(c *gin.Context) string { - if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" { - if i := strings.Index(h, ","); i >= 0 { - h = strings.TrimSpace(h[:i]) + if isTrustedForwardedRequest(c) { + if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" { + if i := strings.Index(h, ","); i >= 0 { + h = strings.TrimSpace(h[:i]) + } + if hp, _, err := net.SplitHostPort(h); err == nil { + return hp + } + return h } - if hp, _, err := net.SplitHostPort(h); err == nil { - return hp + if h := c.GetHeader("X-Real-IP"); h != "" { + return h } - return h - } - if h := c.GetHeader("X-Real-IP"); h != "" { - return h } if h, _, err := net.SplitHostPort(c.Request.Host); err == nil { return h diff --git a/web/controller/util.go b/web/controller/util.go index 20601471..94e17513 100644 --- a/web/controller/util.go +++ b/web/controller/util.go @@ -9,29 +9,75 @@ import ( "github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/web/entity" + "github.com/mhsanaei/3x-ui/v3/web/service" "github.com/gin-gonic/gin" ) // getRemoteIp extracts the real IP address from the request headers or remote address. func getRemoteIp(c *gin.Context) string { - if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok { - return ip + remoteIP, ok := extractTrustedIP(c.Request.RemoteAddr) + if !ok { + return "unknown" } - if xff := c.GetHeader("X-Forwarded-For"); xff != "" { - for _, part := range strings.Split(xff, ",") { - if ip, ok := extractTrustedIP(part); ok { - return ip + if isTrustedProxy(remoteIP) { + if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok { + return ip + } + + if xff := c.GetHeader("X-Forwarded-For"); xff != "" { + for _, part := range strings.Split(xff, ",") { + if ip, ok := extractTrustedIP(part); ok { + return ip + } } } } - if ip, ok := extractTrustedIP(c.Request.RemoteAddr); ok { - return ip + return remoteIP +} + +func isTrustedForwardedRequest(c *gin.Context) bool { + remoteIP, ok := extractTrustedIP(c.Request.RemoteAddr) + return ok && isTrustedProxy(remoteIP) +} + +func isTrustedProxy(ip string) bool { + addr, err := netip.ParseAddr(ip) + if err != nil { + return false } - return "unknown" + trusted := trustedProxyCIDRs() + for _, value := range strings.Split(trusted, ",") { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if prefix, err := netip.ParsePrefix(value); err == nil { + if prefix.Contains(addr) { + return true + } + continue + } + if proxyIP, err := netip.ParseAddr(value); err == nil && proxyIP.Unmap() == addr.Unmap() { + return true + } + } + return false +} + +func trustedProxyCIDRs() (trusted string) { + trusted = "127.0.0.1/32,::1/128" + defer func() { + _ = recover() + }() + settingService := service.SettingService{} + if value, err := settingService.GetTrustedProxyCIDRs(); err == nil && strings.TrimSpace(value) != "" { + trusted = value + } + return trusted } func extractTrustedIP(value string) (string, bool) { diff --git a/web/controller/util_test.go b/web/controller/util_test.go new file mode 100644 index 00000000..a8347f9f --- /dev/null +++ b/web/controller/util_test.go @@ -0,0 +1,34 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestGetRemoteIpIgnoresForwardedHeadersFromUntrustedRemote(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + c.Request.RemoteAddr = "203.0.113.10:12345" + c.Request.Header.Set("X-Real-IP", "198.51.100.9") + c.Request.Header.Set("X-Forwarded-For", "198.51.100.8") + + if got := getRemoteIp(c); got != "203.0.113.10" { + t.Fatalf("remote IP = %q, want request remote address", got) + } +} + +func TestGetRemoteIpHonorsForwardedHeadersFromTrustedLoopbackProxy(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + c.Request.RemoteAddr = "127.0.0.1:12345" + c.Request.Header.Set("X-Forwarded-For", "198.51.100.8, 127.0.0.1") + + if got := getRemoteIp(c); got != "198.51.100.8" { + t.Fatalf("remote IP = %q, want forwarded client IP", got) + } +} diff --git a/web/controller/xray_setting.go b/web/controller/xray_setting.go index 11242038..396e0a6a 100644 --- a/web/controller/xray_setting.go +++ b/web/controller/xray_setting.go @@ -213,6 +213,11 @@ func (a *XraySettingController) testOutbound(c *gin.Context) { // Load the test URL from server settings to prevent SSRF via user-controlled URLs testURL, _ := a.SettingService.GetXrayOutboundTestUrl() + testURL, err := service.SanitizePublicHTTPURL(testURL, false) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON, mode) if err != nil { diff --git a/web/job/xray_traffic_job.go b/web/job/xray_traffic_job.go index 5464a3a0..b434936f 100644 --- a/web/job/xray_traffic_job.go +++ b/web/job/xray_traffic_job.go @@ -152,6 +152,11 @@ func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traf logger.Warning("get ExternalTrafficInformURI failed:", err) return } + informURL, err = service.SanitizePublicHTTPURL(informURL, false) + if err != nil { + logger.Warning("ExternalTrafficInformURI blocked:", err) + return + } requestBody, err := json.Marshal(map[string]any{"clientTraffics": clientTraffics, "inboundTraffics": inboundTraffics}) if err != nil { logger.Warning("parse client/inbound traffic failed:", err) diff --git a/web/middleware/security.go b/web/middleware/security.go index c1ac9dc2..067a1888 100644 --- a/web/middleware/security.go +++ b/web/middleware/security.go @@ -1,6 +1,8 @@ package middleware import ( + "crypto/rand" + "encoding/base64" "net/http" "github.com/mhsanaei/3x-ui/v3/web/session" @@ -11,10 +13,12 @@ import ( // SecurityHeadersMiddleware adds browser hardening headers to panel responses. func SecurityHeadersMiddleware(directHTTPS bool) gin.HandlerFunc { return func(c *gin.Context) { + nonce := newCSPNonce() + c.Set("csp_nonce", nonce) c.Header("X-Content-Type-Options", "nosniff") c.Header("X-Frame-Options", "DENY") c.Header("Referrer-Policy", "no-referrer") - c.Header("Content-Security-Policy", "frame-ancestors 'none'; base-uri 'self'; form-action 'self'") + c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'nonce-"+nonce+"'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'") if directHTTPS { c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains") } @@ -22,6 +26,14 @@ func SecurityHeadersMiddleware(directHTTPS bool) gin.HandlerFunc { } } +func newCSPNonce() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "" + } + return base64.RawStdEncoding.EncodeToString(b[:]) +} + // CSRFMiddleware rejects unsafe requests that do not include the session CSRF token. // Bearer-token-authenticated callers (api_authed flag set by APIController.checkAPIAuth) // short-circuit the CSRF check — they are not browser sessions, so the diff --git a/web/service/node.go b/web/service/node.go index 9cdaf2b7..551e6dd1 100644 --- a/web/service/node.go +++ b/web/service/node.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/http" "strconv" "strings" @@ -99,14 +100,15 @@ func (s *NodeService) Update(id int, in *model.Node) error { return err } updates := map[string]any{ - "name": in.Name, - "remark": in.Remark, - "scheme": in.Scheme, - "address": in.Address, - "port": in.Port, - "base_path": in.BasePath, - "api_token": in.ApiToken, - "enable": in.Enable, + "name": in.Name, + "remark": in.Remark, + "scheme": in.Scheme, + "address": in.Address, + "port": in.Port, + "base_path": in.BasePath, + "api_token": in.ApiToken, + "enable": in.Enable, + "allow_private_address": in.AllowPrivateAddress, } if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil { return err @@ -168,8 +170,13 @@ func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds i func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) { patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()} - url := fmt.Sprintf("%s://%s:%d%spanel/api/server/status", - n.Scheme, n.Address, n.Port, n.BasePath) + hostPort := net.JoinHostPort(n.Address, strconv.Itoa(n.Port)) + url := fmt.Sprintf("%s://%s%spanel/api/server/status", n.Scheme, hostPort, n.BasePath) + url, err := SanitizePublicHTTPURL(url, n.AllowPrivateAddress) + if err != nil { + patch.LastError = err.Error() + return patch, err + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { diff --git a/web/service/panel.go b/web/service/panel.go index 5331eb23..3ab51ab3 100644 --- a/web/service/panel.go +++ b/web/service/panel.go @@ -3,6 +3,7 @@ package service import ( "encoding/json" "fmt" + "io" "net/http" "os" "os/exec" @@ -28,6 +29,11 @@ type PanelUpdateInfo struct { UpdateAvailable bool `json:"updateAvailable"` } +const ( + panelUpdaterURL = "https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh" + maxPanelUpdaterBytes = 2 << 20 +) + func (s *PanelService) RestartPanel(delay time.Duration) error { p, err := os.FindProcess(syscall.Getpid()) if err != nil { @@ -67,13 +73,14 @@ func (s *PanelService) StartUpdate() error { if err != nil { return fmt.Errorf("bash is required to run the panel updater: %w", err) } - curl, err := exec.LookPath("curl") + + scriptPath, err := downloadPanelUpdater() if err != nil { - return fmt.Errorf("curl is required to download the panel updater: %w", err) + return err } mainFolder, serviceFolder := resolveUpdateFolders() - updateScript := fmt.Sprintf("set -o pipefail; %s -fLs https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh | %s", shellQuote(curl), shellQuote(bash)) + updateScript := fmt.Sprintf("set -e; trap 'rm -f %s' EXIT; %s %s", shellQuote(scriptPath), shellQuote(bash), shellQuote(scriptPath)) if systemdRun, err := exec.LookPath("systemd-run"); err == nil { unitName := fmt.Sprintf("x-ui-web-update-%d", time.Now().Unix()) @@ -88,6 +95,7 @@ func (s *PanelService) StartUpdate() error { output := strings.TrimSpace(string(out)) if !strings.Contains(output, "System has not been booted with systemd") && !strings.Contains(output, "Failed to connect to bus") { + _ = os.Remove(scriptPath) return fmt.Errorf("failed to start panel update job: %w: %s", err, output) } logger.Warning("systemd-run is unavailable, falling back to detached update process:", output) @@ -104,6 +112,7 @@ func (s *PanelService) StartUpdate() error { ) setDetachedProcess(cmd) if err := cmd.Start(); err != nil { + _ = os.Remove(scriptPath) return fmt.Errorf("failed to start panel update job: %w", err) } if err := cmd.Process.Release(); err != nil { @@ -113,6 +122,44 @@ func (s *PanelService) StartUpdate() error { return nil } +func downloadPanelUpdater() (string, error) { + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Get(panelUpdaterURL) + if err != nil { + return "", fmt.Errorf("download panel updater: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download panel updater: unexpected HTTP %d", resp.StatusCode) + } + + file, err := os.CreateTemp("", "3x-ui-update-*.sh") + if err != nil { + return "", err + } + path := file.Name() + ok := false + defer func() { + _ = file.Close() + if !ok { + _ = os.Remove(path) + } + }() + + n, err := io.Copy(file, io.LimitReader(resp.Body, maxPanelUpdaterBytes+1)) + if err != nil { + return "", fmt.Errorf("write panel updater: %w", err) + } + if n > maxPanelUpdaterBytes { + return "", fmt.Errorf("panel updater exceeds %d bytes", maxPanelUpdaterBytes) + } + if err := file.Chmod(0700); err != nil { + return "", err + } + ok = true + return path, nil +} + func fetchLatestPanelVersion() (string, error) { client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Get("https://api.github.com/repos/MHSanaei/3x-ui/releases/latest") diff --git a/web/service/server.go b/web/service/server.go index e2ad9deb..b7615a98 100644 --- a/web/service/server.go +++ b/web/service/server.go @@ -14,6 +14,7 @@ import ( "path/filepath" "regexp" "runtime" + "slices" "strconv" "strings" "sync" @@ -493,6 +494,11 @@ func (s *ServerService) sampleCPUUtilization() (float64, error) { var xrayVersionsClient = &http.Client{Timeout: 10 * time.Second} +const ( + maxXrayArchiveBytes = 200 << 20 + maxXrayBinaryBytes = 200 << 20 +) + func (s *ServerService) GetXrayVersions() ([]string, error) { const ( XrayURL = "https://api.github.com/repos/XTLS/Xray-core/releases" @@ -601,28 +607,53 @@ func (s *ServerService) downloadXRay(version string) (string, error) { fileName := fmt.Sprintf("Xray-%s-%s.zip", osName, arch) url := fmt.Sprintf("https://github.com/XTLS/Xray-core/releases/download/%s/%s", version, fileName) - resp, err := http.Get(url) + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Get(url) if err != nil { return "", err } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download xray: unexpected HTTP %d", resp.StatusCode) + } + if resp.ContentLength > maxXrayArchiveBytes { + return "", fmt.Errorf("download xray: archive exceeds %d bytes", maxXrayArchiveBytes) + } - os.Remove(fileName) - file, err := os.Create(fileName) + file, err := os.CreateTemp("", "xray-*.zip") if err != nil { return "", err } - defer file.Close() + path := file.Name() + ok := false + defer func() { + _ = file.Close() + if !ok { + _ = os.Remove(path) + } + }() - _, err = io.Copy(file, resp.Body) + n, err := io.Copy(file, io.LimitReader(resp.Body, maxXrayArchiveBytes+1)) if err != nil { return "", err } + if n > maxXrayArchiveBytes { + return "", fmt.Errorf("download xray: archive exceeds %d bytes", maxXrayArchiveBytes) + } - return fileName, nil + ok = true + return path, nil } func (s *ServerService) UpdateXray(version string) error { + versions, err := s.GetXrayVersions() + if err != nil { + return err + } + if !slices.Contains(versions, version) { + return fmt.Errorf("xray version %q is not in the fetched release list", version) + } + // 1. Stop xray before doing anything if err := s.StopXrayService(); err != nil { logger.Warning("failed to stop xray before update:", err) @@ -657,15 +688,42 @@ func (s *ServerService) UpdateXray(version string) error { return err } defer zipFile.Close() - os.MkdirAll(filepath.Dir(fileName), 0755) - os.Remove(fileName) - file, err := os.OpenFile(fileName, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0755) + if err := os.MkdirAll(filepath.Dir(fileName), 0755); err != nil { + return err + } + tmpFile, err := os.CreateTemp(filepath.Dir(fileName), ".xray-*") if err != nil { return err } - defer file.Close() - _, err = io.Copy(file, zipFile) - return err + tmpPath := tmpFile.Name() + ok := false + defer func() { + _ = tmpFile.Close() + if !ok { + _ = os.Remove(tmpPath) + } + }() + n, err := io.Copy(tmpFile, io.LimitReader(zipFile, maxXrayBinaryBytes+1)) + if err != nil { + return err + } + if n > maxXrayBinaryBytes { + return fmt.Errorf("xray binary exceeds %d bytes", maxXrayBinaryBytes) + } + if err := tmpFile.Chmod(0755); err != nil { + return err + } + if err := tmpFile.Close(); err != nil { + return err + } + if runtime.GOOS == "windows" { + _ = os.Remove(fileName) + } + if err := os.Rename(tmpPath, fileName); err != nil { + return err + } + ok = true + return nil } // 4. Extract correct binary diff --git a/web/service/setting_security_test.go b/web/service/setting_security_test.go new file mode 100644 index 00000000..f4a2255b --- /dev/null +++ b/web/service/setting_security_test.go @@ -0,0 +1,92 @@ +package service + +import ( + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v3/database" +) + +func setupSettingTestDB(t *testing.T) { + t.Helper() + if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := database.CloseDB(); err != nil { + t.Fatal(err) + } + }) +} + +func TestGetAllSettingViewRedactsSecrets(t *testing.T) { + setupSettingTestDB(t) + s := &SettingService{} + if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil { + t.Fatal(err) + } + if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil { + t.Fatal(err) + } + if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil { + t.Fatal(err) + } + if err := s.saveSetting("apiToken", "api-secret"); err != nil { + t.Fatal(err) + } + + view, err := s.GetAllSettingView() + if err != nil { + t.Fatal(err) + } + if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" { + t.Fatalf("settings view leaked secrets: %#v", view) + } + if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken { + t.Fatalf("settings view did not report configured secret flags: %#v", view) + } +} + +func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) { + setupSettingTestDB(t) + s := &SettingService{} + if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil { + t.Fatal(err) + } + if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil { + t.Fatal(err) + } + if err := s.saveSetting("twoFactorEnable", "true"); err != nil { + t.Fatal(err) + } + if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil { + t.Fatal(err) + } + + view, err := s.GetAllSettingView() + if err != nil { + t.Fatal(err) + } + settings := &view.AllSetting + if err := s.UpdateAllSetting(settings); err != nil { + t.Fatal(err) + } + if got, _ := s.GetTgBotToken(); got != "telegram-secret" { + t.Fatalf("tg token = %q, want preserved secret", got) + } + if got, _ := s.GetLdapPassword(); got != "ldap-secret" { + t.Fatalf("ldap password = %q, want preserved secret", got) + } + if got, _ := s.GetTwoFactorToken(); got != "totp-secret" { + t.Fatalf("2fa token = %q, want preserved secret", got) + } +} + +func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) { + if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil { + t.Fatal("expected localhost URL to be blocked") + } + if got, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", true); err != nil || got != "http://127.0.0.1:8080/hook" { + t.Fatalf("allowPrivate result = %q, %v", got, err) + } +} diff --git a/web/service/tgbot.go b/web/service/tgbot.go index bb9028a5..3d737c70 100644 --- a/web/service/tgbot.go +++ b/web/service/tgbot.go @@ -341,15 +341,12 @@ func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*tel // Validate API server URL if provided if apiServerUrl != "" { - if !strings.HasPrefix(apiServerUrl, "http") { - logger.Warning("Invalid http(s) URL for API server, using default") + safeURL, err := SanitizePublicHTTPURL(apiServerUrl, false) + if err != nil { + logger.Warningf("Invalid or blocked API server URL, using default: %v", err) apiServerUrl = "" } else { - _, err := url.Parse(apiServerUrl) - if err != nil { - logger.Warningf("Can't parse API server URL, using default: %v", err) - apiServerUrl = "" - } + apiServerUrl = safeURL } } diff --git a/web/service/url_safety.go b/web/service/url_safety.go new file mode 100644 index 00000000..39ad851c --- /dev/null +++ b/web/service/url_safety.go @@ -0,0 +1,82 @@ +package service + +import ( + "context" + "fmt" + "net" + "net/url" + "strings" + "time" +) + +// SanitizeHTTPURL validates and normalizes an http(s) URL without resolving +// DNS. Use SanitizePublicHTTPURL at the point of an outbound request. +func SanitizeHTTPURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + u, err := url.Parse(raw) + if err != nil { + return "", err + } + if u.Scheme != "http" && u.Scheme != "https" { + return "", fmt.Errorf("unsupported URL scheme %q", u.Scheme) + } + if u.Host == "" || u.Hostname() == "" { + return "", fmt.Errorf("URL host is required") + } + clean := &url.URL{ + Scheme: u.Scheme, + Host: u.Host, + Path: u.Path, + RawPath: u.RawPath, + RawQuery: u.RawQuery, + Fragment: u.Fragment, + } + return clean.String(), nil +} + +// SanitizePublicHTTPURL validates and normalizes an http(s) URL, then blocks +// private/internal targets unless the caller explicitly allows them. +func SanitizePublicHTTPURL(raw string, allowPrivate bool) (string, error) { + clean, err := SanitizeHTTPURL(raw) + if err != nil || clean == "" { + return clean, err + } + if allowPrivate { + return clean, nil + } + u, err := url.Parse(clean) + if err != nil { + return "", err + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := rejectPrivateHost(ctx, u.Hostname()); err != nil { + return "", err + } + return clean, nil +} + +func rejectPrivateHost(ctx context.Context, hostname string) error { + if ip := net.ParseIP(hostname); ip != nil { + if isBlockedIP(ip) { + return fmt.Errorf("blocked private/internal address %s", ip.String()) + } + return nil + } + ips, err := net.DefaultResolver.LookupIPAddr(ctx, hostname) + if err != nil { + return fmt.Errorf("cannot resolve host %s: %w", hostname, err) + } + if len(ips) == 0 { + return fmt.Errorf("host %s has no IP addresses", hostname) + } + for _, ipAddr := range ips { + if isBlockedIP(ipAddr.IP) { + return fmt.Errorf("host %s resolves to blocked private/internal address %s", hostname, ipAddr.IP.String()) + } + } + return nil +} diff --git a/web/web.go b/web/web.go index 4ba70550..ecde3a7e 100644 --- a/web/web.go +++ b/web/web.go @@ -420,7 +420,11 @@ func (s *Server) Start() (err error) { s.listener = listener s.httpServer = &http.Server{ - Handler: engine, + Handler: engine, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, } go func() {