diff --git a/docs/changelog/index.md b/docs/changelog/index.md index b29103ec..dcebe0e8 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -28,6 +28,9 @@ sidebar: false ### 修复 +- 修复 PoW 挑战页潜在 XSS:错误提示与状态文案改用纯文本渲染,挑战通过后的 `redir` 跳转参数仅允许 http/https 协议,防止异常文本被当作 HTML 执行或跳转到危险协议。 +- 修复邮件发送的邮件头注入风险:标题、发件人、收件人在写入邮件头前清除 CR/LF 换行符,防止注入额外邮件头(CWE-93)。 +- 修复 UptimeKuma 同步调试日志泄露凭据:输出日志前对 password/token/secret 等敏感字段打码,避免凭据进入日志。 - 修复 `SWOfflineDomains` 校验与版本比对逻辑:校验器严格拒绝 JSON `null` 输入,配置版本 Diff 比对改用 JSON 序列化避免 `strings.Join` 逗号分隔符歧义。 - 修复 HTML 编辑器预览标题:`HtmlEditorWorkspace` 支持传入 `previewTitle`,离线页预览使用「离线页实时预览」。 diff --git a/internal/apps/agent/nginx/pow_static/js/main.mjs b/internal/apps/agent/nginx/pow_static/js/main.mjs index 7f496602..d3bc1c29 100644 --- a/internal/apps/agent/nginx/pow_static/js/main.mjs +++ b/internal/apps/agent/nginx/pow_static/js/main.mjs @@ -28,5 +28,5 @@ used under the terms of the Apache 2 license. @licend The above is the entire license notice for the JavaScript code in this page. */ -(()=>{var I=()=>navigator.hardwareConcurrency!==void 0?navigator.hardwareConcurrency:1;function _(e,n,s=5,o=null,i,u=Math.trunc(Math.max(I()/2,1))){console.debug("fast algo");let a="purejs";return window.isSecureContext&&(a="webcrypto"),(navigator.userAgent.includes("Firefox")||navigator.userAgent.includes("Goanna"))&&(console.log("Firefox detected, using pure-JS fallback"),a="purejs"),new Promise((E,x)=>{let M=`${e.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${a}.mjs?cacheBuster=${e.version}`,p=[],d=!1,b=()=>{console.log("PoW aborted"),h(),x(new DOMException("Aborted","AbortError"))},h=()=>{d||(d=!0,p.forEach(c=>c.terminate()),o?.removeEventListener("abort",b))};if(o!=null){if(o.aborted)return b();o.addEventListener("abort",b,{once:!0})}for(let c=0;c{typeof m.data=="number"?i?.(m.data):(h(),E(m.data))},g.onerror=m=>{h(),x(m)},g.postMessage({data:n,difficulty:s,nonce:c,threads:u}),p.push(g)}})}var j={fast:_,slow:_};var v=(e="",n={})=>{let s=new URL(e,window.location.href);return Object.entries(n).forEach(([o,i])=>s.searchParams.set(o,i)),s.toString()},L=e=>{let n=document.getElementById(e);return n===null?null:JSON.parse(n.textContent)},k=(e,n,s)=>v(`${s}/.within.website/x/cmd/anubis/static/img/${e}.webp`,{cacheBuster:n});var W=async()=>document.documentElement.lang,S=async e=>{let n=L("anubis_base_prefix");if(n!==null)try{return await(await fetch(`${n}/.within.website/x/cmd/anubis/static/locales/${e}.json`)).json()}catch(s){if(console.warn(`Failed to load translations for ${e}, falling back to English`),e!=="en")return await S("en");throw s}},C=()=>{let e=L("anubis_public_url");if(e!==null)return e&&window.location.href.startsWith(e)?new URLSearchParams(window.location.search).get("redir"):window.location.href},$={},D,A=async()=>{D=await W(),$=await S(D)},r=e=>$[`js_${e}`]||$[e]||e;(async()=>{await A();let e=[{name:"Web Workers",msg:r("web_workers_error"),value:window.Worker},{name:"Cookies",msg:r("cookies_error"),value:navigator.cookieEnabled}],n=document.getElementById("status"),s=document.getElementById("image"),o=document.getElementById("title"),i=document.getElementById("progress"),u=L("anubis_version"),a=L("anubis_base_prefix"),E=document.querySelector("details"),x=!1;E&&E.addEventListener("toggle",()=>{E.open&&(x=!0)});let M=({titleMsg:l,statusMsg:f,imageSrc:w})=>{o.innerHTML=l,n.innerHTML=f,s.src=w,i.style.display="none"};n.innerHTML=r("calculating");for(let{value:l,name:f,msg:w}of e)if(!l){M({titleMsg:`${r("missing_feature")} ${f}`,statusMsg:w,imageSrc:k("reject",u,a)});return}let{challenge:p,rules:d}=L("anubis_challenge"),b=j[d.algorithm];if(!b){M({titleMsg:r("challenge_error"),statusMsg:r("challenge_error_msg"),imageSrc:k("reject",u,a)});return}n.innerHTML=`${r("calculating_difficulty")} ${d.difficulty}, `,i.style.display="inline-block";let h=document.createTextNode(`${r("speed")} 0kH/s`);n.appendChild(h);let c=0,g=!1,m=Math.pow(16,-d.difficulty);try{let l=Date.now(),{hash:f,nonce:w}=await b({basePrefix:a,version:u},p.randomData,d.difficulty,null,t=>{let y=Date.now()-l;y-c>1e3&&(c=y,h.data=`${r("speed")} ${(t/y).toFixed(3)}kH/s`);let T=Math.pow(1-m,t),P=(1-Math.pow(T,2))*100;i["aria-valuenow"]=P,i.firstElementChild!==null&&(i.firstElementChild.style.width=`${P}%`),T<.1&&!g&&(n.append(document.createElement("br"),document.createTextNode(r("verification_longer"))),g=!0)}),H=Date.now();if(console.log({hash:f,nonce:w}),x){let y=function(){let T=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:T,elapsedTime:H-l}))},t=document.getElementById("progress");t.style.display="flex",t.style.alignItems="center",t.style.justifyContent="center",t.style.height="2rem",t.style.borderRadius="1rem",t.style.cursor="pointer",t.style.background="#b16286",t.style.color="white",t.style.fontWeight="bold",t.style.outline="4px solid #b16286",t.style.outlineOffset="2px",t.style.width="min(20rem, 90%)",t.style.margin="1rem auto 2rem",t.innerHTML=r("finished_reading"),t.onclick=y,setTimeout(y,3e4)}else{let t=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:t,elapsedTime:H-l}))}}catch(l){M({titleMsg:r("calculation_error"),statusMsg:`${r("calculation_error_msg")} ${l.message}`,imageSrc:k("reject",u,a)})}})();})(); +(()=>{var I=()=>navigator.hardwareConcurrency!==void 0?navigator.hardwareConcurrency:1;function _(e,n,s=5,o=null,i,u=Math.trunc(Math.max(I()/2,1))){console.debug("fast algo");let a="purejs";return window.isSecureContext&&(a="webcrypto"),(navigator.userAgent.includes("Firefox")||navigator.userAgent.includes("Goanna"))&&(console.log("Firefox detected, using pure-JS fallback"),a="purejs"),new Promise((E,x)=>{let M=`${e.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${a}.mjs?cacheBuster=${e.version}`,p=[],d=!1,b=()=>{console.log("PoW aborted"),h(),x(new DOMException("Aborted","AbortError"))},h=()=>{d||(d=!0,p.forEach(c=>c.terminate()),o?.removeEventListener("abort",b))};if(o!=null){if(o.aborted)return b();o.addEventListener("abort",b,{once:!0})}for(let c=0;c{typeof m.data=="number"?i?.(m.data):(h(),E(m.data))},g.onerror=m=>{h(),x(m)},g.postMessage({data:n,difficulty:s,nonce:c,threads:u}),p.push(g)}})}var j={fast:_,slow:_};var v=(e="",n={})=>{let s=new URL(e,window.location.href);return Object.entries(n).forEach(([o,i])=>s.searchParams.set(o,i)),s.toString()},L=e=>{let n=document.getElementById(e);return n===null?null:JSON.parse(n.textContent)},k=(e,n,s)=>v(`${s}/.within.website/x/cmd/anubis/static/img/${e}.webp`,{cacheBuster:n});var W=async()=>document.documentElement.lang,S=async e=>{let n=L("anubis_base_prefix");if(n!==null)try{return await(await fetch(`${n}/.within.website/x/cmd/anubis/static/locales/${e}.json`)).json()}catch(s){if(console.warn(`Failed to load translations for ${e}, falling back to English`),e!=="en")return await S("en");throw s}},C=()=>{let e=L("anubis_public_url");if(e!==null&&e&&window.location.href.startsWith(e)){let t=new URLSearchParams(window.location.search).get("redir");if(t){try{let u=new URL(t,window.location.href);if(u.protocol==="http:"||u.protocol==="https:")return t}catch(s){}}return window.location.href}return window.location.href},$={},D,A=async()=>{D=await W(),$=await S(D)},r=e=>$[`js_${e}`]||$[e]||e;(async()=>{await A();let e=[{name:"Web Workers",msg:r("web_workers_error"),value:window.Worker},{name:"Cookies",msg:r("cookies_error"),value:navigator.cookieEnabled}],n=document.getElementById("status"),s=document.getElementById("image"),o=document.getElementById("title"),i=document.getElementById("progress"),u=L("anubis_version"),a=L("anubis_base_prefix"),E=document.querySelector("details"),x=!1;E&&E.addEventListener("toggle",()=>{E.open&&(x=!0)});let M=({titleMsg:l,statusMsg:f,imageSrc:w})=>{o.textContent=l,n.textContent=f,s.src=w,i.style.display="none"};n.textContent=r("calculating");for(let{value:l,name:f,msg:w}of e)if(!l){M({titleMsg:`${r("missing_feature")} ${f}`,statusMsg:w,imageSrc:k("reject",u,a)});return}let{challenge:p,rules:d}=L("anubis_challenge"),b=j[d.algorithm];if(!b){M({titleMsg:r("challenge_error"),statusMsg:r("challenge_error_msg"),imageSrc:k("reject",u,a)});return}n.textContent=`${r("calculating_difficulty")} ${d.difficulty}, `,i.style.display="inline-block";let h=document.createTextNode(`${r("speed")} 0kH/s`);n.appendChild(h);let c=0,g=!1,m=Math.pow(16,-d.difficulty);try{let l=Date.now(),{hash:f,nonce:w}=await b({basePrefix:a,version:u},p.randomData,d.difficulty,null,t=>{let y=Date.now()-l;y-c>1e3&&(c=y,h.data=`${r("speed")} ${(t/y).toFixed(3)}kH/s`);let T=Math.pow(1-m,t),P=(1-Math.pow(T,2))*100;i["aria-valuenow"]=P,i.firstElementChild!==null&&(i.firstElementChild.style.width=`${P}%`),T<.1&&!g&&(n.append(document.createElement("br"),document.createTextNode(r("verification_longer"))),g=!0)}),H=Date.now();if(console.log({hash:f,nonce:w}),x){let y=function(){let T=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:T,elapsedTime:H-l}))},t=document.getElementById("progress");t.style.display="flex",t.style.alignItems="center",t.style.justifyContent="center",t.style.height="2rem",t.style.borderRadius="1rem",t.style.cursor="pointer",t.style.background="#b16286",t.style.color="white",t.style.fontWeight="bold",t.style.outline="4px solid #b16286",t.style.outlineOffset="2px",t.style.width="min(20rem, 90%)",t.style.margin="1rem auto 2rem",t.textContent=r("finished_reading"),t.onclick=y,setTimeout(y,3e4)}else{let t=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:t,elapsedTime:H-l}))}}catch(l){M({titleMsg:r("calculation_error"),statusMsg:`${r("calculation_error_msg")} ${l.message}`,imageSrc:k("reject",u,a)})}})();})(); //# sourceMappingURL=main.mjs.map diff --git a/internal/apps/openflare/uptimekuma/client.go b/internal/apps/openflare/uptimekuma/client.go index 193d14aa..501ded5e 100644 --- a/internal/apps/openflare/uptimekuma/client.go +++ b/internal/apps/openflare/uptimekuma/client.go @@ -17,6 +17,35 @@ import ( "time" ) +// redactSensitiveJSON masks values of sensitive keys (password, token, secret, +// api key) so credentials never appear in logs. +func redactSensitiveJSON(v any) any { + switch t := v.(type) { + case map[string]any: + for k, val := range t { + if isSensitiveLogKey(k) { + t[k] = "***" + } else { + t[k] = redactSensitiveJSON(val) + } + } + case []any: + for i, val := range t { + t[i] = redactSensitiveJSON(val) + } + } + return v +} + +func isSensitiveLogKey(k string) bool { + switch strings.ToLower(k) { + case "password", "passwd", "secret", "token", "access_token", "api_key", "apikey": + return true + default: + return false + } +} + const emitAckTimeout = 10 * time.Second // Monitor represents a monitor entry from Uptime Kuma. @@ -292,7 +321,14 @@ func (c *SocketIOClient) Emit(event string, args ...any) (string, error) { } body := fmt.Sprintf("42%d%s", id, string(bs)) - slog.Debug("Emitting Socket.IO event", "event", event, "ackID", id, "payload", string(bs)) + logPayload := string(bs) + var decoded any + if err := json.Unmarshal(bs, &decoded); err == nil { + if redacted, err := json.Marshal(redactSensitiveJSON(decoded)); err == nil { + logPayload = string(redacted) + } + } + slog.Debug("Emitting Socket.IO event", "event", event, "ackID", id, "payload", logPayload) u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid) req, err := http.NewRequestWithContext(c.ctx, http.MethodPost, u, strings.NewReader(body)) diff --git a/internal/apps/openflare/uptimekuma/client_test.go b/internal/apps/openflare/uptimekuma/client_test.go new file mode 100644 index 00000000..5aa2a2fd --- /dev/null +++ b/internal/apps/openflare/uptimekuma/client_test.go @@ -0,0 +1,28 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package uptimekuma + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRedactSensitiveJSON(t *testing.T) { + payload := []byte(`["login",{"username":"admin","password":"s3cret","nested":{"token":"abc","label":"keep"}},"plain"]`) + var decoded any + require.NoError(t, json.Unmarshal(payload, &decoded)) + out, err := json.Marshal(redactSensitiveJSON(decoded)) + require.NoError(t, err) + + s := string(out) + assert.NotContains(t, s, "s3cret") + assert.NotContains(t, s, "abc") + assert.Contains(t, s, `"password":"***"`) + assert.Contains(t, s, `"token":"***"`) + assert.Contains(t, s, `"label":"keep"`) + assert.Contains(t, s, `"username":"admin"`) +} diff --git a/pkg/mail/mail.go b/pkg/mail/mail.go index 0c117ec8..ed34ccad 100644 --- a/pkg/mail/mail.go +++ b/pkg/mail/mail.go @@ -12,6 +12,7 @@ import ( "net" "net/smtp" "strconv" + "strings" "time" ) @@ -29,6 +30,14 @@ type Config struct { Password string } +// sanitizeHeaderValue removes CR/LF bytes so untrusted values cannot inject +// additional email headers (email header injection). +func sanitizeHeaderValue(v string) string { + v = strings.ReplaceAll(v, "\r", "") + v = strings.ReplaceAll(v, "\n", "") + return v +} + // SendMail sends an HTML email using the provided config and message details func SendMail(ctx context.Context, cfg Config, to string, subject, body string) error { return SendMailHTML(ctx, cfg, to, subject, body) @@ -40,9 +49,9 @@ func SendMailHTML(ctx context.Context, cfg Config, to string, subject, body stri // Header & MIME settings for HTML email header := make(map[string]string) - header["From"] = cfg.Username - header["To"] = to - header["Subject"] = subject + header["From"] = sanitizeHeaderValue(cfg.Username) + header["To"] = sanitizeHeaderValue(to) + header["Subject"] = sanitizeHeaderValue(subject) header["MIME-Version"] = "1.0" header["Content-Type"] = "text/html; charset=UTF-8" @@ -212,9 +221,9 @@ func SendMailWithLog(ctx context.Context, cfg Config, to string, subject, body s // Header & MIME settings for HTML email header := make(map[string]string) - header["From"] = cfg.Username - header["To"] = to - header["Subject"] = subject + header["From"] = sanitizeHeaderValue(cfg.Username) + header["To"] = sanitizeHeaderValue(to) + header["Subject"] = sanitizeHeaderValue(subject) header["MIME-Version"] = "1.0" header["Content-Type"] = "text/html; charset=UTF-8" diff --git a/pkg/mail/mail_test.go b/pkg/mail/mail_test.go index 2fcc60be..9b69740e 100644 --- a/pkg/mail/mail_test.go +++ b/pkg/mail/mail_test.go @@ -90,3 +90,23 @@ func TestSendMailMock(t *testing.T) { t.Errorf("failed to send mail: %v", err) } } + +func TestSanitizeHeaderValue(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"plain", "System Notification", "System Notification"}, + {"crlf stripped", "alert\r\nBcc: attacker@example.com", "alertBcc: attacker@example.com"}, + {"cr stripped", "a\rb", "ab"}, + {"lf stripped", "a\nb", "ab"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sanitizeHeaderValue(tt.input); got != tt.want { + t.Errorf("sanitizeHeaderValue(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/pkg/push/email.go b/pkg/push/email.go index ba8d289b..62c748a5 100644 --- a/pkg/push/email.go +++ b/pkg/push/email.go @@ -19,6 +19,14 @@ func init() { // EmailPusher 极简 SMTP 邮件推送实现 (静态、解耦) type EmailPusher struct{} +// sanitizeEmailHeader removes CR/LF bytes so untrusted values cannot inject +// additional email headers (email header injection). +func sanitizeEmailHeader(v string) string { + v = strings.ReplaceAll(v, "\r", "") + v = strings.ReplaceAll(v, "\n", "") + return v +} + // Send 发送邮件 func (p *EmailPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, _ string, ext map[string]any) error { if cfg.URL == "" || cfg.Key == "" || cfg.Secret == "" { @@ -57,9 +65,9 @@ func (p *EmailPusher) Send(ctx context.Context, cfg Config, target string, body } } - subjectHeader := fmt.Sprintf("Subject: %s\r\n", title) - fromHeader := fmt.Sprintf("From: %s <%s>\r\n", fromName, from) - toHeader := fmt.Sprintf("To: %s\r\n", to) + subjectHeader := fmt.Sprintf("Subject: %s\r\n", sanitizeEmailHeader(title)) + fromHeader := fmt.Sprintf("From: %s <%s>\r\n", sanitizeEmailHeader(fromName), sanitizeEmailHeader(from)) + toHeader := fmt.Sprintf("To: %s\r\n", sanitizeEmailHeader(to)) mimeHeader := "MIME-version: 1.0;\r\nContent-Type: text/html; charset=\"UTF-8\";\r\n\r\n" // 拼装完整的邮件报文 diff --git a/pkg/push/email_test.go b/pkg/push/email_test.go new file mode 100644 index 00000000..fb7367cc --- /dev/null +++ b/pkg/push/email_test.go @@ -0,0 +1,26 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import "testing" + +func TestSanitizeEmailHeader(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"plain", "System Notification", "System Notification"}, + {"crlf stripped", "alert\r\nBcc: attacker@example.com", "alertBcc: attacker@example.com"}, + {"cr stripped", "a\rb", "ab"}, + {"lf stripped", "a\nb", "ab"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sanitizeEmailHeader(tt.input); got != tt.want { + t.Errorf("sanitizeEmailHeader(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +}