mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Comments, doc comments, UI labels, log and error messages, LLM prompt strings, markdown docs, test data and assertions. Identifiers, enum keys, routes, placeholders and code structure are unchanged. Notes: - docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md - tests that exercised CJK specific handling (GBK zip names, Chinese full text search) now use ASCII equivalents; go build passes and the test suite matches the pre translation baseline (remaining failures need a PostgreSQL DSN and fail identically on the original) - frontend mention syntax now uses English kinds (finding, asset, ...) matching the backend, chat mentions tests pass
249 lines
9.7 KiB
Go
249 lines
9.7 KiB
Go
package notify
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// This file holds invariant tests: **no** error text escaping from a channel
|
|
// implementation may carry credentials.
|
|
//
|
|
// Why a separate file: the original channel cases covered only success paths and
|
|
// platform business errors, never looking at transport-layer failures. Yet
|
|
// transport-layer errors (connection refused / DNS failure / timeout) are the
|
|
// most dangerous — the *url.Error returned by http.Client.Do prints the **full
|
|
// URL** into the error text, and the credentials of these platforms are in the
|
|
// URL. The credentials rode this string to four outlets:
|
|
//
|
|
// notification_deliveries.last_error → stored in plaintext
|
|
// GET /api/notify/deliveries response → bypasses the channel config masking, echoed to the browser
|
|
// server logs → often exported and retained
|
|
// the test-send API's 502 response → popped up directly in the frontend
|
|
//
|
|
// So instead of testing one function, this file fires one doomed-to-fail request
|
|
// per channel and asserts the credential cannot be found in the error text.
|
|
|
|
// credentialCases covers every channel shape where "the credential is in the
|
|
// URL": DingTalk/WeCom in the query, Feishu in the last path segment, Telegram
|
|
// in the middle of the path.
|
|
var credentialCases = []struct {
|
|
name string
|
|
ch Channel
|
|
cfg map[string]any
|
|
secret string
|
|
}{
|
|
{
|
|
name: "DingTalk access_token in query",
|
|
ch: dingTalkChannel{},
|
|
cfg: map[string]any{"webhook": "http://127.0.0.1:1/robot/send?access_token=" + leakProbeToken},
|
|
secret: leakProbeToken,
|
|
},
|
|
{
|
|
name: "WeCom key in query",
|
|
ch: weComChannel{},
|
|
cfg: map[string]any{"webhook": "http://127.0.0.1:1/cgi-bin/webhook/send?key=" + leakProbeToken},
|
|
secret: leakProbeToken,
|
|
},
|
|
{
|
|
name: "Feishu hook id in last path segment",
|
|
ch: feishuChannel{},
|
|
cfg: map[string]any{"webhook": "http://127.0.0.1:1/open-apis/bot/v2/hook/" + leakProbeToken},
|
|
secret: leakProbeToken,
|
|
},
|
|
{
|
|
name: "Telegram bot token in the middle of the path",
|
|
ch: telegramChannel{},
|
|
cfg: map[string]any{"bot_token": leakProbeToken, "chat_id": "1", "base_url": "http://127.0.0.1:1"},
|
|
secret: leakProbeToken,
|
|
},
|
|
{
|
|
name: "DingTalk signing secret",
|
|
ch: dingTalkChannel{},
|
|
cfg: map[string]any{"webhook": "http://127.0.0.1:1/robot/send", "secret": leakProbeToken},
|
|
secret: leakProbeToken,
|
|
},
|
|
}
|
|
|
|
// leakProbeToken is a sentinel value that can never be a real credential, used
|
|
// to search for it in error text.
|
|
const leakProbeToken = "LEAKPROBE0123456789abcdef"
|
|
|
|
// TestChannelErrorsNeverLeakCredentials is the core invariant.
|
|
func TestChannelErrorsNeverLeakCredentials(t *testing.T) {
|
|
for _, tc := range credentialCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// A doomed peer: nobody listens on 127.0.0.1:1, exercising the
|
|
// connection-refused path.
|
|
_, err := tc.ch.Send(context.Background(), tc.cfg, Message{
|
|
Items: []Item{{FindingID: 1, Severity: "high", Name: "leak probe"}},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("an unreachable address should error")
|
|
}
|
|
assertNoSecret(t, err.Error(), tc.secret)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestChannelErrorsNeverLeakCredentialsInPermanentPath covers the permanent
|
|
// failure branch: URL validation failures, platform business errors, etc. also
|
|
// send error text outward, and likewise must not carry credentials.
|
|
func TestChannelErrorsNeverLeakCredentialsInPermanentPath(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
ch Channel
|
|
cfg map[string]any
|
|
}{
|
|
// Address carries a credential but is malformed → exercises the
|
|
// validateHTTPURL / url.Parse branch.
|
|
{"DingTalk address malformed", dingTalkChannel{}, map[string]any{"webhook": "file:///" + leakProbeToken}},
|
|
{"WeCom address malformed", weComChannel{}, map[string]any{"webhook": "gopher://" + leakProbeToken}},
|
|
{"Feishu address malformed", feishuChannel{}, map[string]any{"webhook": "ftp://" + leakProbeToken + "/hook"}},
|
|
{"Telegram API address malformed", telegramChannel{}, map[string]any{"bot_token": "tok", "chat_id": "1", "base_url": "file://" + leakProbeToken}},
|
|
{"generic webhook address malformed", webhookChannel{}, map[string]any{"url": "javascript:" + leakProbeToken}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := tc.ch.Send(context.Background(), tc.cfg, Message{Items: []Item{{Severity: "high"}}})
|
|
if err == nil {
|
|
t.Fatal("an invalid configuration should error")
|
|
}
|
|
assertNoSecret(t, err.Error(), leakProbeToken)
|
|
})
|
|
}
|
|
}
|
|
|
|
func assertNoSecret(t *testing.T, text, secret string) {
|
|
t.Helper()
|
|
if strings.Contains(text, secret) {
|
|
t.Fatalf("error text leaked the credential %q:\n %s", secret, text)
|
|
}
|
|
}
|
|
|
|
func TestRedactRequestTargetKeepsOnlySchemeAndHost(t *testing.T) {
|
|
cases := map[string]string{
|
|
"https://oapi.dingtalk.com/robot/send?access_token=S1": "https://oapi.dingtalk.com/…",
|
|
"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=S2": "https://qyapi.weixin.qq.com/…",
|
|
"https://open.feishu.cn/open-apis/bot/v2/hook/S3": "https://open.feishu.cn/…",
|
|
"https://api.telegram.org/botS4/sendMessage": "https://api.telegram.org/…",
|
|
"http://10.0.0.5:8080/hook": "http://10.0.0.5:8080/…",
|
|
}
|
|
for in, want := range cases {
|
|
got := redactRequestTarget(in)
|
|
if got != want {
|
|
t.Errorf("redactRequestTarget(%q) = %q, want %q", in, got, want)
|
|
}
|
|
// The redacted result itself must no longer contain any path/query
|
|
// fragment of the original address.
|
|
if parts := strings.SplitN(in, "://", 2); len(parts) == 2 {
|
|
if hostAndRest := strings.SplitN(parts[1], "/", 2); len(hostAndRest) == 2 && hostAndRest[1] != "" {
|
|
if strings.Contains(got, hostAndRest[1]) {
|
|
t.Errorf("path/query fragment %q still present after redaction: %q", hostAndRest[1], got)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Unparseable input is never echoed back.
|
|
for _, bad := range []string{"", "://", "not a url", "http://"} {
|
|
if got := redactRequestTarget(bad); strings.Contains(got, bad) && bad != "" {
|
|
t.Errorf("unparseable input %q was echoed back as %q", bad, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRedactTransportErrorStripsURL targets the concrete *url.Error type: it is
|
|
// what http.Client.Do returns, and the first scene of the leak.
|
|
func TestRedactTransportErrorStripsURL(t *testing.T) {
|
|
inner := errors.New("dial tcp 127.0.0.1:1: connect: connection refused")
|
|
uerr := &url.Error{
|
|
Op: "Post",
|
|
URL: "https://api.telegram.org/bot" + leakProbeToken + "/sendMessage",
|
|
Err: inner,
|
|
}
|
|
got := redactTransportError(uerr)
|
|
assertNoSecret(t, got, leakProbeToken)
|
|
if !strings.Contains(got, "api.telegram.org") {
|
|
t.Errorf("the host should be kept for troubleshooting, got %q", got)
|
|
}
|
|
if !strings.Contains(got, "connection refused") {
|
|
t.Errorf("the underlying cause should be kept for troubleshooting, got %q", got)
|
|
}
|
|
// The Op is also kept (POST vs GET matters for troubleshooting).
|
|
if !strings.Contains(got, "Post") {
|
|
t.Errorf("the operation name should be kept, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestRedactURLsInTextHandlesFallback is the fallback path: addresses inside
|
|
// custom errors that are not *url.Error (such as the errors returned by the
|
|
// redirect policy) must likewise be stripped.
|
|
func TestRedactURLsInTextHandlesFallback(t *testing.T) {
|
|
in := fmt.Sprintf("refusing cross-host redirect (a.example → http://b.example/bot%s/send)", leakProbeToken)
|
|
got := redactURLsInText(in)
|
|
assertNoSecret(t, got, leakProbeToken)
|
|
if !strings.Contains(got, "http://b.example/…") {
|
|
t.Errorf("addresses should be replaced with the redacted form, got %q", got)
|
|
}
|
|
// Text without addresses is kept as-is.
|
|
if plain := "dial tcp: connection refused"; redactURLsInText(plain) != plain {
|
|
t.Error("text without addresses should not be modified")
|
|
}
|
|
}
|
|
|
|
// TestCrossHostRedirectRefused covers "credentials in the URL + following a
|
|
// cross-host redirect = handing over the credentials". The two httptest servers
|
|
// listen on different ports of 127.0.0.1, and different ports mean different
|
|
// Hosts — exactly a cross-host redirect.
|
|
func TestCrossHostRedirectRefused(t *testing.T) {
|
|
var hit bool
|
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
hit = true
|
|
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
|
|
}))
|
|
defer target.Close()
|
|
|
|
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, target.URL+"/robot/send?access_token="+leakProbeToken, http.StatusTemporaryRedirect)
|
|
}))
|
|
defer redirector.Close()
|
|
|
|
_, err := (dingTalkChannel{}).Send(context.Background(),
|
|
map[string]any{"webhook": redirector.URL + "/robot/send?access_token=" + leakProbeToken},
|
|
Message{Items: []Item{{Severity: "high"}}})
|
|
if err == nil {
|
|
t.Fatal("cross-host redirects should be refused")
|
|
}
|
|
if hit {
|
|
t.Fatal("the redirect target was hit — the credentials leaked along with the redirect")
|
|
}
|
|
assertNoSecret(t, err.Error(), leakProbeToken)
|
|
}
|
|
|
|
// TestSameHostRedirectAllowed is the counterpart: same-host redirects (such as
|
|
// adding a trailing slash) must keep working, otherwise normal workflows would
|
|
// be blocked along with the attack.
|
|
func TestSameHostRedirectAllowed(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/robot/send" {
|
|
// Same-host, same-port redirect.
|
|
http.Redirect(w, r, "/robot/send/", http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if _, err := (dingTalkChannel{}).Send(context.Background(),
|
|
map[string]any{"webhook": srv.URL + "/robot/send"},
|
|
Message{Items: []Item{{Severity: "high"}}}); err != nil {
|
|
t.Fatalf("same-host redirects should not be refused: %v", err)
|
|
}
|
|
}
|