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

#4135
This commit is contained in:
MHSanaei
2026-05-04 16:36:33 +02:00
parent 9f96ef83ec
commit c90f8a05bf
23 changed files with 147 additions and 85 deletions

View File

@@ -1,8 +1,10 @@
package controller package controller
import ( import (
"fmt"
"net" "net"
"net/http" "net/http"
"net/netip"
"strings" "strings"
"github.com/mhsanaei/3x-ui/v2/config" "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. // getRemoteIp extracts the real IP address from the request headers or remote address.
func getRemoteIp(c *gin.Context) string { func getRemoteIp(c *gin.Context) string {
value := c.GetHeader("X-Real-IP") if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok {
if value != "" { return ip
return value
} }
value = c.GetHeader("X-Forwarded-For")
if value != "" { if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
ips := strings.Split(value, ",") for _, part := range strings.Split(xff, ",") {
return ips[0] if ip, ok := extractTrustedIP(part); ok {
return ip
}
}
} }
addr := c.Request.RemoteAddr
ip, _, _ := net.SplitHostPort(addr) if ip, ok := extractTrustedIP(c.Request.RemoteAddr); ok {
return ip 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. // jsonMsg sends a JSON response with a message and error status.

View File

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

View File

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

View File

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

View File

@@ -175,7 +175,7 @@
} }
}); });
Vue.component('a-table-sort-trigger', { Vue.component('a-table-sort-trigger', {
template: `{{template "component/sortableTableTrigger"}}`, template: `{{template "component/sortableTableTrigger" .}}`,
props: { props: {
'item-index': { 'item-index': {
type: undefined, type: undefined,

View File

@@ -95,7 +95,7 @@
} }
const themeSwitcher = createThemeSwitcher(); const themeSwitcher = createThemeSwitcher();
Vue.component('a-theme-switch', { Vue.component('a-theme-switch', {
template: `{{template "component/themeSwitchTemplate"}}`, template: `{{template "component/themeSwitchTemplate" .}}`,
data: () => ({ data: () => ({
themeSwitcher themeSwitcher
}), }),
@@ -107,7 +107,7 @@
} }
}); });
Vue.component('a-theme-switch-login', { Vue.component('a-theme-switch-login', {
template: `{{template "component/themeSwitchTemplateLogin"}}`, template: `{{template "component/themeSwitchTemplateLogin" .}}`,
data: () => ({ data: () => ({
themeSwitcher themeSwitcher
}), }),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -150,7 +150,11 @@
}, },
initHeadline() { initHeadline() {
const animationDelay = 2000; 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) => { headlines.forEach((headline) => {
const first = headline.querySelector('.is-visible'); const first = headline.querySelector('.is-visible');
if (!first) return; if (!first) return;

View File

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

View File

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

View File

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

View File

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

View File

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