Files
3m-ui/backend/internal/subpage/render.go
T
風雪 c852f0266a fix(backend): path traversal, mihomo secret, template injection, acme path
- H-3: subpage loadTemplate accepted arbitrary theme_dir, reachable
from the unauthenticated /api/v1/client/sub/:token?html=1 endpoint.
Add sanitizeThemeDir() restricting to /var/lib/3m-ui/themes,
/etc/3m-ui/themes, /usr/local/share/3m-ui/themes. Validated on both
load and save.
- M-1: Mihomo external-controller shipped with an empty secret. Even
bound to loopback, any co-located unprivileged process could drive
the REST API. Generate a process-scoped random secret via crypto/rand
(ControllerSecret()); traffic collector already reads it from the
generated config.
- H-4: ACMECommand / WARPTemplate / ReverseProxyTemplate used
fmt.Sprintf with unescaped user input (shell / YAML / nginx injection
if admin account is hijacked). Add isValidDomain() and shellQuote().
ACMECommand and WARPTemplate now return (string, error). WARP uses
yaml.Marshal for structured output. api.go handlers updated.
- LOW-12: ACME cert_file/key_file paths now restricted to an allowlist
(/etc/ssl/, /etc/letsencrypt/, /etc/3m-ui/, /var/lib/3m-ui/, etc.).
fix(backend): path traversal, mihomo secret, template injection, acme path
- H-3: subpage loadTemplate accepted arbitrary theme_dir, reachable
from the unauthenticated /api/v1/client/sub/:token?html=1 endpoint.
Add sanitizeThemeDir() restricting to /var/lib/3m-ui/themes,
/etc/3m-ui/themes, /usr/local/share/3m-ui/themes. Validated on both
load and save.
- M-1: Mihomo external-controller shipped with an empty secret. Even
bound to loopback, any co-located unprivileged process could drive
the REST API. Generate a process-scoped random secret via crypto/rand
(ControllerSecret()); traffic collector already reads it from the
generated config.
- H-4: ACMECommand / WARPTemplate / ReverseProxyTemplate used
fmt.Sprintf with unescaped user input (shell / YAML / nginx injection
if admin account is hijacked). Add isValidDomain() and shellQuote().
ACMECommand and WARPTemplate now return (string, error). WARP uses
yaml.Marshal for structured output. api.go handlers updated.
- LOW-12: ACME cert_file/key_file paths now restricted to an allowlist
(/etc/ssl/, /etc/letsencrypt/, /etc/3m-ui/, /var/lib/3m-ui/, etc.).
fix(backend): path traversal, mihomo secret, template injection, acme path
- H-3: subpage loadTemplate accepted arbitrary theme_dir, reachable
from the unauthenticated /api/v1/client/sub/:token?html=1 endpoint.
Add sanitizeThemeDir() restricting to /var/lib/3m-ui/themes,
/etc/3m-ui/themes, /usr/local/share/3m-ui/themes. Validated on both
load and save.
- M-1: Mihomo external-controller shipped with an empty secret. Even
bound to loopback, any co-located unprivileged process could drive
the REST API. Generate a process-scoped random secret via crypto/rand
(ControllerSecret()); traffic collector already reads it from the
generated config.
- H-4: ACMECommand / WARPTemplate / ReverseProxyTemplate used
fmt.Sprintf with unescaped user input (shell / YAML / nginx injection
if admin account is hijacked). Add isValidDomain() and shellQuote().
ACMECommand and WARPTemplate now return (string, error). WARP uses
yaml.Marshal for structured output. api.go handlers updated.
- LOW-12: ACME cert_file/key_file paths now restricted to an allowlist
(/etc/ssl/, /etc/letsencrypt/, /etc/3m-ui/, /var/lib/3m-ui/, etc.).
fix(backend): path traversal, mihomo secret, template injection, acme path
- H-3: subpage loadTemplate accepted arbitrary theme_dir, reachable
from the unauthenticated /api/v1/client/sub/:token?html=1 endpoint.
Add sanitizeThemeDir() restricting to /var/lib/3m-ui/themes,
/etc/3m-ui/themes, /usr/local/share/3m-ui/themes. Validated on both
load and save.
- M-1: Mihomo external-controller shipped with an empty secret. Even
bound to loopback, any co-located unprivileged process could drive
the REST API. Generate a process-scoped random secret via crypto/rand
(ControllerSecret()); traffic collector already reads it from the
generated config.
- H-4: ACMECommand / WARPTemplate / ReverseProxyTemplate used
fmt.Sprintf with unescaped user input (shell / YAML / nginx injection
if admin account is hijacked). Add isValidDomain() and shellQuote().
ACMECommand and WARPTemplate now return (string, error). WARP uses
yaml.Marshal for structured output. api.go handlers updated.
- LOW-12: ACME cert_file/key_file paths now restricted to an allowlist
(/etc/ssl/, /etc/letsencrypt/, /etc/3m-ui/, /var/lib/3m-ui/, etc.).
fix(backend): path traversal, mihomo secret, template injection, acme path
- H-3: subpage loadTemplate accepted arbitrary theme_dir, reachable
from the unauthenticated /api/v1/client/sub/:token?html=1 endpoint.
Add sanitizeThemeDir() restricting to /var/lib/3m-ui/themes,
/etc/3m-ui/themes, /usr/local/share/3m-ui/themes. Validated on both
load and save.
- M-1: Mihomo external-controller shipped with an empty secret. Even
bound to loopback, any co-located unprivileged process could drive
the REST API. Generate a process-scoped random secret via crypto/rand
(ControllerSecret()); traffic collector already reads it from the
generated config.
- H-4: ACMECommand / WARPTemplate / ReverseProxyTemplate used
fmt.Sprintf with unescaped user input (shell / YAML / nginx injection
if admin account is hijacked). Add isValidDomain() and shellQuote().
ACMECommand and WARPTemplate now return (string, error). WARP uses
yaml.Marshal for structured output. api.go handlers updated.
- LOW-12: ACME cert_file/key_file paths now restricted to an allowlist
(/etc/ssl/, /etc/letsencrypt/, /etc/3m-ui/, /var/lib/3m-ui/, etc.).
fix(backend): path traversal, mihomo secret, template injection, acme path
- H-3: subpage loadTemplate accepted arbitrary theme_dir, reachable
from the unauthenticated /api/v1/client/sub/:token?html=1 endpoint.
Add sanitizeThemeDir() restricting to /var/lib/3m-ui/themes,
/etc/3m-ui/themes, /usr/local/share/3m-ui/themes. Validated on both
load and save.
- M-1: Mihomo external-controller shipped with an empty secret. Even
bound to loopback, any co-located unprivileged process could drive
the REST API. Generate a process-scoped random secret via crypto/rand
(ControllerSecret()); traffic collector already reads it from the
generated config.
- H-4: ACMECommand / WARPTemplate / ReverseProxyTemplate used
fmt.Sprintf with unescaped user input (shell / YAML / nginx injection
if admin account is hijacked). Add isValidDomain() and shellQuote().
ACMECommand and WARPTemplate now return (string, error). WARP uses
yaml.Marshal for structured output. api.go handlers updated.
- LOW-12: ACME cert_file/key_file paths now restricted to an allowlist
(/etc/ssl/, /etc/letsencrypt/, /etc/3m-ui/, /var/lib/3m-ui/, etc.).
fix(backend): path traversal, mihomo secret, template injection, acme path
- H-3: subpage loadTemplate accepted arbitrary theme_dir, reachable
from the unauthenticated /api/v1/client/sub/:token?html=1 endpoint.
Add sanitizeThemeDir() restricting to /var/lib/3m-ui/themes,
/etc/3m-ui/themes, /usr/local/share/3m-ui/themes. Validated on both
load and save.
- M-1: Mihomo external-controller shipped with an empty secret. Even
bound to loopback, any co-located unprivileged process could drive
the REST API. Generate a process-scoped random secret via crypto/rand
(ControllerSecret()); traffic collector already reads it from the
generated config.
- H-4: ACMECommand / WARPTemplate / ReverseProxyTemplate used
fmt.Sprintf with unescaped user input (shell / YAML / nginx injection
if admin account is hijacked). Add isValidDomain() and shellQuote().
ACMECommand and WARPTemplate now return (string, error). WARP uses
yaml.Marshal for structured output. api.go handlers updated.
- LOW-12: ACME cert_file/key_file paths now restricted to an allowlist
(/etc/ssl/, /etc/letsencrypt/, /etc/3m-ui/, /var/lib/3m-ui/, etc.).
fix(backend): path traversal, mihomo secret, template injection, acme path
- H-3: subpage loadTemplate accepted arbitrary theme_dir, reachable
from the unauthenticated /api/v1/client/sub/:token?html=1 endpoint.
Add sanitizeThemeDir() restricting to /var/lib/3m-ui/themes,
/etc/3m-ui/themes, /usr/local/share/3m-ui/themes. Validated on both
load and save.
- M-1: Mihomo external-controller shipped with an empty secret. Even
bound to loopback, any co-located unprivileged process could drive
the REST API. Generate a process-scoped random secret via crypto/rand
(ControllerSecret()); traffic collector already reads it from the
generated config.
- H-4: ACMECommand / WARPTemplate / ReverseProxyTemplate used
fmt.Sprintf with unescaped user input (shell / YAML / nginx injection
if admin account is hijacked). Add isValidDomain() and shellQuote().
ACMECommand and WARPTemplate now return (string, error). WARP uses
yaml.Marshal for structured output. api.go handlers updated.
- LOW-12: ACME cert_file/key_file paths now restricted to an allowlist
(/etc/ssl/, /etc/letsencrypt/, /etc/3m-ui/, /var/lib/3m-ui/, etc.).
fix(backend): path traversal, mihomo secret, template injection, acme path
- H-3: subpage loadTemplate accepted arbitrary theme_dir, reachable
from the unauthenticated /api/v1/client/sub/:token?html=1 endpoint.
Add sanitizeThemeDir() restricting to /var/lib/3m-ui/themes,
/etc/3m-ui/themes, /usr/local/share/3m-ui/themes. Validated on both
load and save.
- M-1: Mihomo external-controller shipped with an empty secret. Even
bound to loopback, any co-located unprivileged process could drive
the REST API. Generate a process-scoped random secret via crypto/rand
(ControllerSecret()); traffic collector already reads it from the
generated config.
- H-4: ACMECommand / WARPTemplate / ReverseProxyTemplate used
fmt.Sprintf with unescaped user input (shell / YAML / nginx injection
if admin account is hijacked). Add isValidDomain() and shellQuote().
ACMECommand and WARPTemplate now return (string, error). WARP uses
yaml.Marshal for structured output. api.go handlers updated.
- LOW-12: ACME cert_file/key_file paths now restricted to an allowlist
(/etc/ssl/, /etc/letsencrypt/, /etc/3m-ui/, /var/lib/3m-ui/, etc.).
fix(backend): path traversal, mihomo secret, template injection, acme path
- H-3: subpage loadTemplate accepted arbitrary theme_dir, reachable
from the unauthenticated /api/v1/client/sub/:token?html=1 endpoint.
Add sanitizeThemeDir() restricting to /var/lib/3m-ui/themes,
/etc/3m-ui/themes, /usr/local/share/3m-ui/themes. Validated on both
load and save.
- M-1: Mihomo external-controller shipped with an empty secret. Even
bound to loopback, any co-located unprivileged process could drive
the REST API. Generate a process-scoped random secret via crypto/rand
(ControllerSecret()); traffic collector already reads it from the
generated config.
- H-4: ACMECommand / WARPTemplate / ReverseProxyTemplate used
fmt.Sprintf with unescaped user input (shell / YAML / nginx injection
if admin account is hijacked). Add isValidDomain() and shellQuote().
ACMECommand and WARPTemplate now return (string, error). WARP uses
yaml.Marshal for structured output. api.go handlers updated.
- LOW-12: ACME cert_file/key_file paths now restricted to an allowlist
(/etc/ssl/, /etc/letsencrypt/, /etc/3m-ui/, /var/lib/3m-ui/, etc.).
fix(backend): path traversal, mihomo secret, template injection, acme path
- H-3: subpage loadTemplate accepted arbitrary theme_dir, reachable
from the unauthenticated /api/v1/client/sub/:token?html=1 endpoint.
Add sanitizeThemeDir() restricting to /var/lib/3m-ui/themes,
/etc/3m-ui/themes, /usr/local/share/3m-ui/themes. Validated on both
load and save.
- M-1: Mihomo external-controller shipped with an empty secret. Even
bound to loopback, any co-located unprivileged process could drive
the REST API. Generate a process-scoped random secret via crypto/rand
(ControllerSecret()); traffic collector already reads it from the
generated config.
- H-4: ACMECommand / WARPTemplate / ReverseProxyTemplate used
fmt.Sprintf with unescaped user input (shell / YAML / nginx injection
if admin account is hijacked). Add isValidDomain() and shellQuote().
ACMECommand and WARPTemplate now return (string, error). WARP uses
yaml.Marshal for structured output. api.go handlers updated.
- LOW-12: ACME cert_file/key_file paths now restricted to an allowlist
(/etc/ssl/, /etc/letsencrypt/, /etc/3m-ui/, /var/lib/3m-ui/, etc.).
fix(backend): path traversal, mihomo secret, template injection, acme path
- H-3: subpage loadTemplate accepted arbitrary theme_dir, reachable
from the unauthenticated /api/v1/client/sub/:token?html=1 endpoint.
Add sanitizeThemeDir() restricting to /var/lib/3m-ui/themes,
/etc/3m-ui/themes, /usr/local/share/3m-ui/themes. Validated on both
load and save.
- M-1: Mihomo external-controller shipped with an empty secret. Even
bound to loopback, any co-located unprivileged process could drive
the REST API. Generate a process-scoped random secret via crypto/rand
(ControllerSecret()); traffic collector already reads it from the
generated config.
- H-4: ACMECommand / WARPTemplate / ReverseProxyTemplate used
fmt.Sprintf with unescaped user input (shell / YAML / nginx injection
if admin account is hijacked). Add isValidDomain() and shellQuote().
ACMECommand and WARPTemplate now return (string, error). WARP uses
yaml.Marshal for structured output. api.go handlers updated.
- LOW-12: ACME cert_file/key_file paths now restricted to an allowlist
(/etc/ssl/, /etc/letsencrypt/, /etc/3m-ui/, /var/lib/3m-ui/, etc.).
2026-08-29 09:38:43 +00:00

297 lines
9.2 KiB
Go

package subpage
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"html/template"
"os"
"path/filepath"
"strings"
"time"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
"gorm.io/gorm"
)
const (
settingKeyThemeDir = "sub_theme_dir"
settingKeyTitle = "sub_title"
settingKeySupport = "sub_support_url"
settingKeyAnnounce = "sub_announce"
settingKeyWebPage = "sub_web_page_url"
settingKeyUpdates = "sub_updates"
settingKeyEncrypt = "sub_encrypt"
)
// ViewModel is the data passed to custom / built-in subscription HTML templates
// (custom subscription page theme).
type ViewModel struct {
Username string
Remark string
Enabled bool
Online bool
TrafficUsed int64
TrafficLimit int64
UploadBytes int64
DownloadBytes int64
ExpireTime string
IPLimit int
SubURL string
SubJSONURL string
SubClashURL string
SubV2RayURL string
SubTitle string
SubSupportURL string
IsOnline bool
Links []string
}
// Settings holds subscription page branding options stored in PanelSetting.
type Settings struct {
ThemeDir string `json:"theme_dir"`
Title string `json:"title"`
SupportURL string `json:"support_url"`
Announce string `json:"announce"`
WebPageURL string `json:"web_page_url"`
UpdateHours int `json:"update_hours"` // Profile-Update-Interval
Encrypt bool `json:"encrypt"` // base64-encode raw URI list
}
func LoadPageSettings(db *gorm.DB) Settings {
s := Settings{Title: "3m-ui Subscription", UpdateHours: 12, Encrypt: true}
if db == nil {
return s
}
s.ThemeDir = getSetting(db, settingKeyThemeDir)
if t := getSetting(db, settingKeyTitle); t != "" {
s.Title = t
}
s.SupportURL = getSetting(db, settingKeySupport)
s.Announce = getSetting(db, settingKeyAnnounce)
s.WebPageURL = getSetting(db, settingKeyWebPage)
if v := getSetting(db, settingKeyUpdates); v != "" {
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 {
s.UpdateHours = n
}
}
if v := getSetting(db, settingKeyEncrypt); v != "" {
s.Encrypt = v == "1" || strings.EqualFold(v, "true")
}
return s
}
func SavePageSettings(db *gorm.DB, s Settings) error {
if db == nil {
return fmt.Errorf("database is not configured")
}
if s.UpdateHours <= 0 {
s.UpdateHours = 12
}
enc := "true"
if !s.Encrypt {
enc = "false"
}
themeDir := sanitizeThemeDir(s.ThemeDir)
if strings.TrimSpace(s.ThemeDir) != "" && themeDir == "" {
return fmt.Errorf("theme_dir must be empty or under an allowed theme root")
}
for _, kv := range []struct{ k, v string }{
{settingKeyThemeDir, themeDir},
{settingKeyTitle, strings.TrimSpace(s.Title)},
{settingKeySupport, strings.TrimSpace(s.SupportURL)},
{settingKeyAnnounce, strings.TrimSpace(s.Announce)},
{settingKeyWebPage, strings.TrimSpace(s.WebPageURL)},
{settingKeyUpdates, fmt.Sprintf("%d", s.UpdateHours)},
{settingKeyEncrypt, enc},
} {
if err := upsertSetting(db, kv.k, kv.v); err != nil {
return err
}
}
return nil
}
func getSetting(db *gorm.DB, key string) string {
var row models.PanelSetting
if err := db.Where("key = ?", key).First(&row).Error; err != nil {
return ""
}
return row.Value
}
func upsertSetting(db *gorm.DB, key, value string) error {
var row models.PanelSetting
err := db.Where("key = ?", key).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return db.Create(&models.PanelSetting{Key: key, Value: value}).Error
}
if err != nil {
return err
}
row.Value = value
return db.Save(&row).Error
}
// RenderHTML renders the subscription information page for a proxy user.
func RenderHTML(db *gorm.DB, pu models.ProxyUser, subBase string, links []string) ([]byte, error) {
page := LoadPageSettings(db)
expire := ""
if !pu.ExpireTime.IsZero() {
expire = pu.ExpireTime.UTC().Format(time.RFC3339)
}
base := strings.TrimSuffix(subBase, "/")
vm := ViewModel{
Username: pu.Username,
Remark: pu.Remark,
Enabled: pu.Enabled,
Online: pu.Online,
TrafficUsed: pu.TrafficUsed,
TrafficLimit: pu.TrafficLimit,
UploadBytes: pu.UploadBytes,
DownloadBytes: pu.DownloadBytes,
ExpireTime: expire,
IPLimit: pu.IPLimit,
SubURL: base,
SubJSONURL: base + "?target=singbox",
SubClashURL: base + "?target=clash",
SubV2RayURL: base + "?target=v2ray",
SubTitle: page.Title,
SubSupportURL: page.SupportURL,
IsOnline: pu.Online,
Links: links,
}
tpl, err := loadTemplate(page.ThemeDir)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, vm); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// allowedThemeRoots enumerates the directories under which a custom subscription
// page theme may live. Path-traversal outside these roots is rejected so an
// attacker (or hijacked admin session) cannot point the HTML template loader at
// arbitrary files on the host (e.g. /etc/passwd, database files).
var allowedThemeRoots = []string{
"/var/lib/3m-ui/themes",
"/etc/3m-ui/themes",
"/usr/local/share/3m-ui/themes",
}
// sanitizeThemeDir returns the cleaned absolute theme directory if it lives
// under one of the allowed roots, otherwise the empty string. An empty input
// is allowed (falls back to the built-in default template).
func sanitizeThemeDir(themeDir string) string {
themeDir = strings.TrimSpace(themeDir)
if themeDir == "" {
return ""
}
clean := filepath.Clean(themeDir)
if !filepath.IsAbs(clean) {
return ""
}
for _, root := range allowedThemeRoots {
rel, err := filepath.Rel(root, clean)
if err != nil {
continue
}
if rel == "." {
return clean
}
if !strings.HasPrefix(rel, "..") && !strings.Contains(rel, string(filepath.Separator)+"..") {
return clean
}
}
return ""
}
func loadTemplate(themeDir string) (*template.Template, error) {
themeDir = sanitizeThemeDir(themeDir)
if themeDir != "" {
for _, name := range []string{"index.html", "sub.html"} {
path := filepath.Join(themeDir, name)
if st, err := os.Stat(path); err == nil && !st.IsDir() {
return template.ParseFiles(path)
}
}
}
return template.New("sub").Parse(defaultHTML)
}
// DefaultTemplate returns the built-in HTML for documentation / preview.
func DefaultTemplate() string {
return defaultHTML
}
// ExportSettingsJSON is a convenience for API responses.
func ExportSettingsJSON(db *gorm.DB) ([]byte, error) {
s := LoadPageSettings(db)
return json.Marshal(s)
}
const defaultHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>{{if .SubTitle}}{{.SubTitle}}{{else}}Subscription{{end}}</title>
<style>
:root { --bg:#0f1419; --card:#1a2332; --text:#e7ecf3; --muted:#8b9bb4; --accent:#3b82f6; --ok:#22c55e; --warn:#f59e0b; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--text); min-height:100vh; }
.wrap { max-width:640px; margin:0 auto; padding:24px 16px 48px; }
h1 { font-size:1.4rem; margin:0 0 4px; }
.sub { color:var(--muted); font-size:.9rem; margin-bottom:20px; }
.card { background:var(--card); border-radius:12px; padding:16px 18px; margin-bottom:14px; }
.row { display:flex; justify-content:space-between; gap:12px; padding:6px 0; font-size:.92rem; }
.row span:first-child { color:var(--muted); }
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:.75rem; background:#243044; }
.badge.ok { background:rgba(34,197,94,.15); color:var(--ok); }
.badge.warn { background:rgba(245,158,11,.15); color:var(--warn); }
.links a { display:block; color:var(--accent); text-decoration:none; word-break:break-all; margin:8px 0; font-size:.85rem; }
.links a:hover { text-decoration:underline; }
.qr { text-align:center; margin-top:12px; }
.qr img { border-radius:8px; background:#fff; padding:8px; }
footer { margin-top:24px; text-align:center; color:var(--muted); font-size:.8rem; }
</style>
</head>
<body>
<div class="wrap">
<h1>{{if .SubTitle}}{{.SubTitle}}{{else}}Subscription{{end}}</h1>
<p class="sub">{{.Username}}{{if .Remark}} · {{.Remark}}{{end}}</p>
<div class="card">
<div class="row"><span>Status</span>
<span>
{{if .Enabled}}<span class="badge ok">Enabled</span>{{else}}<span class="badge warn">Disabled</span>{{end}}
{{if .IsOnline}}<span class="badge ok">Online</span>{{else}}<span class="badge">Offline</span>{{end}}
</span>
</div>
<div class="row"><span>Traffic</span>
<span>{{.TrafficUsed}} / {{if gt .TrafficLimit 0}}{{.TrafficLimit}}{{else}}∞{{end}} bytes</span>
</div>
<div class="row"><span>Expire</span><span>{{if .ExpireTime}}{{.ExpireTime}}{{else}}Never{{end}}</span></div>
<div class="row"><span>IP limit</span><span>{{if gt .IPLimit 0}}{{.IPLimit}}{{else}}∞{{end}}</span></div>
</div>
<div class="card links">
<div class="row"><span>Formats</span></div>
<a href="{{.SubURL}}">Mihomo / Clash (YAML)</a>
<a href="{{.SubV2RayURL}}">V2Ray / Base64</a>
<a href="{{.SubClashURL}}">Clash target</a>
{{if .SubSupportURL}}<a href="{{.SubSupportURL}}" rel="noopener">Support</a>{{end}}
</div>
<footer>Powered by 3m-ui · refresh client subscription to pick up changes</footer>
</div>
</body>
</html>
`