浏览代码

fix(security): sanitize remote IP headers and escape log viewer output

#4135
MHSanaei 1 天之前
父节点
当前提交
c90f8a05bf

+ 52 - 10
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
+			}
+		}
+	}
+
+	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
 	}
-	addr := c.Request.RemoteAddr
-	ip, _, _ := net.SplitHostPort(addr)
-	return ip
+	return ip.Unmap(), true
 }
 
 // jsonMsg sends a JSON response with a message and error status.

+ 1 - 1
web/html/component/aCustomStatistic.html

@@ -38,7 +38,7 @@
         required: false
       }
     },
-    template: `{{template "component/customStatistic"}}`,
+    template: `{{template "component/customStatistic" .}}`,
   });
 </script>
 {{end}}

+ 1 - 1
web/html/component/aPersianDatepicker.html

@@ -34,7 +34,7 @@
                 required: false,
             },
         },
-        template: `{{template "component/persianDatepickerTemplate"}}`,
+        template: `{{template "component/persianDatepickerTemplate" .}}`,
         data() {
             return {
                 date: '',

+ 1 - 1
web/html/component/aSidebar.html

@@ -96,7 +96,7 @@
                 }
             }
         },
-        template: `{{template "component/sidebar/content"}}`,
+        template: `{{template "component/sidebar/content" .}}`,
     });
 </script>
 {{end}}

+ 1 - 1
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,

+ 2 - 2
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
     }),

+ 14 - 14
web/html/form/inbound.html

@@ -102,69 +102,69 @@
 
 <!-- vmess settings -->
 <template v-if="inbound.protocol === Protocols.VMESS">
-    {{template "form/vmess"}}
+    {{template "form/vmess" .}}
 </template>
 
 <!-- vless settings -->
 <template v-if="inbound.protocol === Protocols.VLESS">
-    {{template "form/vless"}}
+    {{template "form/vless" .}}
 </template>
 
 <!-- trojan settings -->
 <template v-if="inbound.protocol === Protocols.TROJAN">
-    {{template "form/trojan"}}
+    {{template "form/trojan" .}}
 </template>
 
 <!-- shadowsocks -->
 <template v-if="inbound.protocol === Protocols.SHADOWSOCKS">
-    {{template "form/shadowsocks"}}
+    {{template "form/shadowsocks" .}}
 </template>
 
 <!-- tunnel -->
 <template v-if="inbound.protocol === Protocols.TUNNEL">
-    {{template "form/tunnel"}}
+    {{template "form/tunnel" .}}
 </template>
 
 <!-- mixed -->
 <template v-if="inbound.protocol === Protocols.MIXED">
-    {{template "form/mixed"}}
+    {{template "form/mixed" .}}
 </template>
 
 <!-- http -->
 <template v-if="inbound.protocol === Protocols.HTTP">
-    {{template "form/http"}}
+    {{template "form/http" .}}
 </template>
 
 <!-- wireguard -->
 <template v-if="inbound.protocol === Protocols.WIREGUARD">
-    {{template "form/wireguard"}}
+    {{template "form/wireguard" .}}
 </template>
 
 <!-- tun -->
 <template v-if="inbound.protocol === Protocols.TUN">
-    {{template "form/tun"}}
+    {{template "form/tun" .}}
 </template>
 
 <!-- hysteria -->
 <template v-if="inbound.protocol === Protocols.HYSTERIA">
-    {{template "form/hysteria"}}
+    {{template "form/hysteria" .}}
 </template>
 
 <!-- stream settings -->
 <template v-if="inbound.canEnableStream()">
-    {{template "form/streamSettings"}}
-    {{template "form/externalProxy" }}
+    {{template "form/streamSettings" .}}
+    {{template "form/externalProxy" .}}
 </template>
 
 <!-- tls settings -->
 <template v-if="inbound.canEnableTls()">
-    {{template "form/tlsSettings"}}
+    {{template "form/tlsSettings" .}}
 </template>
 
 <!-- sniffing -->
 <a-collapse>
     <a-collapse-panel header='Sniffing'>
-        {{template "form/sniffing"}}
+        {{template "form/sniffing" .}}
     </a-collapse-panel>
 </a-collapse>
 

+ 1 - 1
web/html/form/protocol/dokodemo.html

@@ -32,6 +32,6 @@
 </a-form>
 <!-- sockopt -->
 <template>
-    {{template "form/streamSockopt"}}
+    {{template "form/streamSockopt" .}}
 </template>
 {{end}}

+ 1 - 1
web/html/form/protocol/hysteria.html

@@ -1,7 +1,7 @@
 {{define "form/hysteria"}}
 <a-collapse activeKey="0" v-for="(client, index) in inbound.settings.hysterias.slice(0,1)" v-if="!isEdit">
     <a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
-        {{template "form/client"}}
+        {{template "form/client" .}}
     </a-collapse-panel>
 </a-collapse>
 <a-collapse v-else>

+ 1 - 1
web/html/form/protocol/shadowsocks.html

@@ -2,7 +2,7 @@
 <template v-if="inbound.isSSMultiUser">
   <a-collapse activeKey="0" v-for="(client, index) in inbound.settings.shadowsockses.slice(0,1)" v-if="!isEdit">
     <a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
-      {{template "form/client"}}
+      {{template "form/client" .}}
     </a-collapse-panel>
   </a-collapse>
   <a-collapse v-else>

+ 1 - 1
web/html/form/protocol/trojan.html

@@ -1,7 +1,7 @@
 {{define "form/trojan"}}
 <a-collapse activeKey="0" v-for="(client, index) in inbound.settings.trojans.slice(0,1)" v-if="!isEdit">
   <a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
-    {{template "form/client"}}
+    {{template "form/client" .}}
   </a-collapse-panel>
 </a-collapse>
 <a-collapse v-else>

+ 1 - 1
web/html/form/protocol/vless.html

@@ -1,7 +1,7 @@
 {{define "form/vless"}}
 <a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vlesses.slice(0,1)" v-if="!isEdit">
   <a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
-    {{template "form/client"}}
+    {{template "form/client" .}}
   </a-collapse-panel>
 </a-collapse>
 <a-collapse v-else>

+ 1 - 1
web/html/form/protocol/vmess.html

@@ -1,7 +1,7 @@
 {{define "form/vmess"}}
 <a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vmesses.slice(0,1)" v-if="!isEdit">
     <a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
-        {{template "form/client"}}
+        {{template "form/client" .}}
     </a-collapse-panel>
 </a-collapse>
 <a-collapse v-else>

+ 9 - 9
web/html/form/stream/stream_settings.html

@@ -17,42 +17,42 @@
 
 <!-- tcp -->
 <template v-if="inbound.stream.network === 'tcp'">
-  {{template "form/streamTCP"}}
+  {{template "form/streamTCP" .}}
 </template>
 
 <!-- kcp -->
 <template v-if="inbound.stream.network === 'kcp'">
-  {{template "form/streamKCP"}}
+  {{template "form/streamKCP" .}}
 </template>
 
 <!-- ws -->
 <template v-if="inbound.stream.network === 'ws'">
-  {{template "form/streamWS"}}
+  {{template "form/streamWS" .}}
 </template>
 
 <!-- grpc -->
 <template v-if="inbound.stream.network === 'grpc'">
-  {{template "form/streamGRPC"}}
+  {{template "form/streamGRPC" .}}
 </template>
 
 <!-- hysteria -->
 <template v-if="inbound.stream.network === 'hysteria'">
-  {{template "form/streamHysteria"}}
+  {{template "form/streamHysteria" .}}
 </template>
 
 <!-- httpupgrade -->
 <template v-if="inbound.stream.network === 'httpupgrade'">
-  {{template "form/streamHTTPUpgrade"}}
+  {{template "form/streamHTTPUpgrade" .}}
 </template>
 
 <!-- xhttp -->
 <template v-if="inbound.stream.network === 'xhttp'">
-  {{template "form/streamXHTTP"}}
+  {{template "form/streamXHTTP" .}}
 </template>
 
 <!-- sockopt -->
-<template> {{template "form/streamSockopt"}} </template>
+<template> {{template "form/streamSockopt" .}} </template>
 
 <!-- finalmask -->
-<template> {{template "form/streamFinalMask"}} </template>
+<template> {{template "form/streamFinalMask" .}} </template>
 {{end}}

+ 1 - 1
web/html/form/tls_settings.html

@@ -132,7 +132,7 @@
 
   <!-- reality settings -->
   <template v-if="inbound.stream.isReality">
-    {{template "form/realitySettings"}}
+    {{template "form/realitySettings" .}}
   </template>
 </a-form>
 {{end}}

+ 8 - 8
web/html/inbounds.html

@@ -645,7 +645,7 @@
                       <a-table :row-key="client => client.id" :columns="isMobile ? innerMobileColumns : innerColumns"
                         :data-source="getInboundClients(record)" :pagination=pagination(getInboundClients(record))
                         :style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }">
-                        {{template "component/aClientTable"}}
+                        {{template "component/aClientTable" .}}
                       </a-table>
                     </template>
                   </a-table>
@@ -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" .}}
 <a-modal id="copy-clients-modal" :title="copyClientsModal.title" :visible="copyClientsModal.visible"
   :confirm-loading="copyClientsModal.confirmLoading" ok-text='{{ i18n "pages.client.copySelected" }}'
   cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme" :closable="true" :mask-closable="false"

+ 33 - 17
web/html/index.html

@@ -564,7 +564,7 @@
 {{template "component/aSidebar" .}}
 {{template "component/aThemeSwitch" .}}
 {{template "component/aCustomStatistic" .}}
-{{template "modals/textModal"}}
+{{template "modals/textModal" .}}
 <script>
   // 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, '&amp;')
+      .replace(/</g, '&lt;')
+      .replace(/>/g, '&gt;')
+      .replace(/"/g, '&quot;')
+      .replace(/'/g, '&#39;');
+  };
+
   const logModal = {
     visible: false,
     logs: [],
@@ -986,24 +998,28 @@
         if (index > 0) formattedLogs += '<br>';
 
         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 += `<span style="color: gray;">${index + 1}.</span>`;
           formattedLogs += `<span style="color: ${levelColors[0]};">${d} ${t}</span> `;
           formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${level}</span>`;
         } else {
-          const levelIndex = levels.indexOf(data, levels) || 5;
-          formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${data}</span>`;
+          const idx = levels.indexOf(data);
+          const levelIndex = idx >= 0 ? idx : 5;
+          formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${escapeHtml(data)}</span>`;
         }
 
         if (message) {
-          if (message.startsWith("XRAY:"))
-            message = "<b>XRAY: </b>" + message.substring(5);
-          else
-            message = "<b>X-UI: </b>" + message;
+          if (message.startsWith("XRAY:")) {
+            message = "<b>XRAY: </b>" + escapeHtml(message.substring(5));
+          } else {
+            message = "<b>X-UI: </b>" + escapeHtml(message);
+          }
         }
 
         formattedLogs += message ? ' - ' + message : '';
@@ -1063,16 +1079,16 @@
 
         let text = ``;
         if (log.Email !== "") {
-          text = `<td>${log.Email}</td>`;
+          text = `<td>${escapeHtml(log.Email)}</td>`;
         }
 
         formattedLogs += `
 <tr ${outboundColor}>
-    <td><b>${IntlUtil.formatDate(log.DateTime)}</b></td>
-    <td>${log.FromAddress}</td>
-    <td>${log.ToAddress}</td>
-    <td>${log.Inbound}</td>
-    <td>${log.Outbound}</td>
+    <td><b>${escapeHtml(IntlUtil.formatDate(log.DateTime))}</b></td>
+    <td>${escapeHtml(log.FromAddress)}</td>
+    <td>${escapeHtml(log.ToAddress)}</td>
+    <td>${escapeHtml(log.Inbound)}</td>
+    <td>${escapeHtml(log.Outbound)}</td>
     ${text}
 </tr>
 `;

+ 5 - 1
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;

+ 1 - 1
web/html/modals/client_modal.html

@@ -7,7 +7,7 @@
             :style="{ marginBottom: '10px', display: 'block', textAlign: 'center' }">Account
             is (Expired|Traffic Ended) And Disabled</a-tag>
     </template>
-    {{template "form/client"}}
+    {{template "form/client" .}}
 </a-modal>
 <script>
     const clientModal = {

+ 1 - 1
web/html/modals/inbound_modal.html

@@ -2,7 +2,7 @@
 <a-modal id="inbound-modal" v-model="inModal.visible" :title="inModal.title" :dialog-style="{ top: '20px' }"
     @ok="inModal.ok" :confirm-loading="inModal.confirmLoading" :closable="true" :mask-closable="false"
     :class="themeSwitcher.currentTheme" :ok-text="inModal.okText" cancel-text='{{ i18n "close" }}'>
-    {{template "form/inbound"}}
+    {{template "form/inbound" .}}
 </a-modal>
 <script>
     // Make inModal globally available to ensure it works with any base path

+ 1 - 1
web/html/modals/xray_outbound_modal.html

@@ -3,7 +3,7 @@
     :confirm-loading="outModal.confirmLoading" :closable="true" :mask-closable="false"
     :ok-button-props="{ props: { disabled: !outModal.isValid } }" :style="{ overflow: 'hidden' }"
     :ok-text="outModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
-    {{template "form/outbound"}}
+    {{template "form/outbound" .}}
 </a-modal>
 <script>
     const outModal = {

+ 1 - 1
web/html/settings.html

@@ -103,7 +103,7 @@
 {{template "component/aSidebar" .}}
 {{template "component/aThemeSwitch" .}}
 {{template "component/aSettingListItem" .}}
-{{template "modals/twoFactorModal"}}
+{{template "modals/twoFactorModal" .}}
 <script>
   const app = new Vue({
     delimiters: ['[[', ']]'],

+ 9 - 9
web/html/xray.html

@@ -133,15 +133,15 @@
 {{template "component/aThemeSwitch" .}}
 {{template "component/aTableSortable" .}}
 {{template "component/aSettingListItem" .}}
-{{template "modals/ruleModal"}}
-{{template "modals/outModal"}}
-{{template "modals/reverseModal"}}
-{{template "modals/balancerModal"}}
-{{template "modals/dnsModal"}}
-{{template "modals/dnsPresetsModal"}}
-{{template "modals/fakednsModal"}}
-{{template "modals/warpModal"}}
-{{template "modals/nordModal"}}
+{{template "modals/ruleModal" .}}
+{{template "modals/outModal" .}}
+{{template "modals/reverseModal" .}}
+{{template "modals/balancerModal" .}}
+{{template "modals/dnsModal" .}}
+{{template "modals/dnsPresetsModal" .}}
+{{template "modals/fakednsModal" .}}
+{{template "modals/warpModal" .}}
+{{template "modals/nordModal" .}}
 <script>
   const rulesColumns = [{
       title: "#",