From c90f8a05bf792e61db250f210834cdabcc0b7906 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 4 May 2026 16:36:33 +0200 Subject: [PATCH] fix(security): sanitize remote IP headers and escape log viewer output #4135 --- web/controller/util.go | 62 ++++++++++++++++++---- web/html/component/aCustomStatistic.html | 2 +- web/html/component/aPersianDatepicker.html | 2 +- web/html/component/aSidebar.html | 2 +- web/html/component/aTableSortable.html | 2 +- web/html/component/aThemeSwitch.html | 4 +- web/html/form/inbound.html | 28 +++++----- web/html/form/protocol/dokodemo.html | 2 +- web/html/form/protocol/hysteria.html | 2 +- web/html/form/protocol/shadowsocks.html | 2 +- web/html/form/protocol/trojan.html | 2 +- web/html/form/protocol/vless.html | 2 +- web/html/form/protocol/vmess.html | 2 +- web/html/form/stream/stream_settings.html | 18 +++---- web/html/form/tls_settings.html | 2 +- web/html/inbounds.html | 16 +++--- web/html/index.html | 50 +++++++++++------ web/html/login.html | 6 ++- web/html/modals/client_modal.html | 2 +- web/html/modals/inbound_modal.html | 2 +- web/html/modals/xray_outbound_modal.html | 2 +- web/html/settings.html | 2 +- web/html/xray.html | 18 +++---- 23 files changed, 147 insertions(+), 85 deletions(-) diff --git a/web/controller/util.go b/web/controller/util.go index 3d266f29..e1d53ba6 100644 --- a/web/controller/util.go +++ b/web/controller/util.go @@ -1,8 +1,10 @@ package controller import ( + "fmt" "net" "net/http" + "net/netip" "strings" "github.com/mhsanaei/3x-ui/v2/config" @@ -14,18 +16,58 @@ import ( // getRemoteIp extracts the real IP address from the request headers or remote address. func getRemoteIp(c *gin.Context) string { - value := c.GetHeader("X-Real-IP") - if value != "" { - return value + if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok { + return ip } - value = c.GetHeader("X-Forwarded-For") - if value != "" { - ips := strings.Split(value, ",") - return ips[0] + + if xff := c.GetHeader("X-Forwarded-For"); xff != "" { + for _, part := range strings.Split(xff, ",") { + if ip, ok := extractTrustedIP(part); ok { + return ip + } + } } - addr := c.Request.RemoteAddr - ip, _, _ := net.SplitHostPort(addr) - return ip + + if ip, ok := extractTrustedIP(c.Request.RemoteAddr); ok { + return ip + } + + return "unknown" +} + +func extractTrustedIP(value string) (string, bool) { + candidate := strings.TrimSpace(value) + if candidate == "" { + return "", false + } + + if ip, ok := parseIPCandidate(candidate); ok { + return ip.String(), true + } + + if host, _, err := net.SplitHostPort(candidate); err == nil { + if ip, ok := parseIPCandidate(host); ok { + return ip.String(), true + } + } + + if strings.Count(candidate, ":") == 1 { + if host, _, err := net.SplitHostPort(fmt.Sprintf("[%s]", candidate)); err == nil { + if ip, ok := parseIPCandidate(host); ok { + return ip.String(), true + } + } + } + + return "", false +} + +func parseIPCandidate(value string) (netip.Addr, bool) { + ip, err := netip.ParseAddr(strings.TrimSpace(value)) + if err != nil { + return netip.Addr{}, false + } + return ip.Unmap(), true } // jsonMsg sends a JSON response with a message and error status. diff --git a/web/html/component/aCustomStatistic.html b/web/html/component/aCustomStatistic.html index e9bfe83b..be20a39a 100644 --- a/web/html/component/aCustomStatistic.html +++ b/web/html/component/aCustomStatistic.html @@ -38,7 +38,7 @@ required: false } }, - template: `{{template "component/customStatistic"}}`, + template: `{{template "component/customStatistic" .}}`, }); {{end}} \ No newline at end of file diff --git a/web/html/component/aPersianDatepicker.html b/web/html/component/aPersianDatepicker.html index e8b09b92..cb4c2918 100644 --- a/web/html/component/aPersianDatepicker.html +++ b/web/html/component/aPersianDatepicker.html @@ -34,7 +34,7 @@ required: false, }, }, - template: `{{template "component/persianDatepickerTemplate"}}`, + template: `{{template "component/persianDatepickerTemplate" .}}`, data() { return { date: '', diff --git a/web/html/component/aSidebar.html b/web/html/component/aSidebar.html index 9c89a96d..08b39dc3 100644 --- a/web/html/component/aSidebar.html +++ b/web/html/component/aSidebar.html @@ -96,7 +96,7 @@ } } }, - template: `{{template "component/sidebar/content"}}`, + template: `{{template "component/sidebar/content" .}}`, }); {{end}} \ No newline at end of file diff --git a/web/html/component/aTableSortable.html b/web/html/component/aTableSortable.html index b3606527..925adbb5 100644 --- a/web/html/component/aTableSortable.html +++ b/web/html/component/aTableSortable.html @@ -175,7 +175,7 @@ } }); Vue.component('a-table-sort-trigger', { - template: `{{template "component/sortableTableTrigger"}}`, + template: `{{template "component/sortableTableTrigger" .}}`, props: { 'item-index': { type: undefined, diff --git a/web/html/component/aThemeSwitch.html b/web/html/component/aThemeSwitch.html index 2107e5a8..2712b1f7 100644 --- a/web/html/component/aThemeSwitch.html +++ b/web/html/component/aThemeSwitch.html @@ -95,7 +95,7 @@ } const themeSwitcher = createThemeSwitcher(); Vue.component('a-theme-switch', { - template: `{{template "component/themeSwitchTemplate"}}`, + template: `{{template "component/themeSwitchTemplate" .}}`, data: () => ({ themeSwitcher }), @@ -107,7 +107,7 @@ } }); Vue.component('a-theme-switch-login', { - template: `{{template "component/themeSwitchTemplateLogin"}}`, + template: `{{template "component/themeSwitchTemplateLogin" .}}`, data: () => ({ themeSwitcher }), diff --git a/web/html/form/inbound.html b/web/html/form/inbound.html index 736a1fd4..61d7bc57 100644 --- a/web/html/form/inbound.html +++ b/web/html/form/inbound.html @@ -102,69 +102,69 @@ - {{template "form/sniffing"}} + {{template "form/sniffing" .}} diff --git a/web/html/form/protocol/dokodemo.html b/web/html/form/protocol/dokodemo.html index 4437a3e3..1dbace29 100644 --- a/web/html/form/protocol/dokodemo.html +++ b/web/html/form/protocol/dokodemo.html @@ -32,6 +32,6 @@ {{end}} \ No newline at end of file diff --git a/web/html/form/protocol/hysteria.html b/web/html/form/protocol/hysteria.html index 557ebb43..5613dfc5 100644 --- a/web/html/form/protocol/hysteria.html +++ b/web/html/form/protocol/hysteria.html @@ -1,7 +1,7 @@ {{define "form/hysteria"}} - {{template "form/client"}} + {{template "form/client" .}} diff --git a/web/html/form/protocol/shadowsocks.html b/web/html/form/protocol/shadowsocks.html index 8112222c..12371399 100644 --- a/web/html/form/protocol/shadowsocks.html +++ b/web/html/form/protocol/shadowsocks.html @@ -2,7 +2,7 @@ @@ -668,13 +668,13 @@ {{template "component/aThemeSwitch" .}} {{template "component/aCustomStatistic" .}} {{template "component/aPersianDatepicker" .}} -{{template "modals/inboundModal"}} -{{template "modals/promptModal"}} -{{template "modals/qrcodeModal"}} -{{template "modals/textModal"}} -{{template "modals/inboundInfoModal"}} -{{template "modals/clientsModal"}} -{{template "modals/clientsBulkModal"}} +{{template "modals/inboundModal" .}} +{{template "modals/promptModal" .}} +{{template "modals/qrcodeModal" .}} +{{template "modals/textModal" .}} +{{template "modals/inboundInfoModal" .}} +{{template "modals/clientsModal" .}} +{{template "modals/clientsBulkModal" .}} // Tiny Sparkline component using an inline SVG polyline Vue.component('sparkline', { @@ -963,6 +963,18 @@ }, }; + const escapeHtml = (value) => { + if (value === null || value === undefined) { + return ''; + } + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }; + const logModal = { visible: false, logs: [], @@ -986,24 +998,28 @@ if (index > 0) formattedLogs += '
'; if (parts.length === 3) { - const d = parts[0]; - const t = parts[1]; - const level = parts[2]; - const levelIndex = levels.indexOf(level, levels) || 5; + const d = escapeHtml(parts[0]); + const t = escapeHtml(parts[1]); + const levelRaw = parts[2]; + const level = escapeHtml(levelRaw); + const idx = levels.indexOf(levelRaw); + const levelIndex = idx >= 0 ? idx : 5; //formattedLogs += `${index + 1}.`; formattedLogs += `${d} ${t} `; formattedLogs += `${level}`; } else { - const levelIndex = levels.indexOf(data, levels) || 5; - formattedLogs += `${data}`; + const idx = levels.indexOf(data); + const levelIndex = idx >= 0 ? idx : 5; + formattedLogs += `${escapeHtml(data)}`; } if (message) { - if (message.startsWith("XRAY:")) - message = "XRAY: " + message.substring(5); - else - message = "X-UI: " + message; + if (message.startsWith("XRAY:")) { + message = "XRAY: " + escapeHtml(message.substring(5)); + } else { + message = "X-UI: " + escapeHtml(message); + } } formattedLogs += message ? ' - ' + message : ''; @@ -1063,16 +1079,16 @@ let text = ``; if (log.Email !== "") { - text = `${log.Email}`; + text = `${escapeHtml(log.Email)}`; } formattedLogs += ` - ${IntlUtil.formatDate(log.DateTime)} - ${log.FromAddress} - ${log.ToAddress} - ${log.Inbound} - ${log.Outbound} + ${escapeHtml(IntlUtil.formatDate(log.DateTime))} + ${escapeHtml(log.FromAddress)} + ${escapeHtml(log.ToAddress)} + ${escapeHtml(log.Inbound)} + ${escapeHtml(log.Outbound)} ${text} `; diff --git a/web/html/login.html b/web/html/login.html index 78bffd30..2e03a4c5 100644 --- a/web/html/login.html +++ b/web/html/login.html @@ -150,7 +150,11 @@ }, initHeadline() { const animationDelay = 2000; - const headlines = this.$el.querySelectorAll('.headline'); + const rootEl = this.$el instanceof Element ? this.$el : document.getElementById('app'); + if (!rootEl || typeof rootEl.querySelectorAll !== 'function') { + return; + } + const headlines = rootEl.querySelectorAll('.headline'); headlines.forEach((headline) => { const first = headline.querySelector('.is-visible'); if (!first) return; diff --git a/web/html/modals/client_modal.html b/web/html/modals/client_modal.html index f66c01e6..65a481f6 100644 --- a/web/html/modals/client_modal.html +++ b/web/html/modals/client_modal.html @@ -7,7 +7,7 @@ :style="{ marginBottom: '10px', display: 'block', textAlign: 'center' }">Account is (Expired|Traffic Ended) And Disabled - {{template "form/client"}} + {{template "form/client" .}}