From eb5c082cc56d9fda3d35e0dbce031a6694b720db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:27:43 +0000 Subject: [PATCH 1/5] chore(deps): bump github/codeql-action from 4.39.0 to 4.39.1 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.39.0 to 4.39.1. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.39.0...v4.39.1) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.39.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 84f459c..9e0fe52 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,14 +41,14 @@ jobs: cache: true - name: Initialize CodeQL - uses: github/codeql-action/init@v4.39.0 + uses: github/codeql-action/init@v4.39.1 with: languages: go - name: Autobuild - uses: github/codeql-action/autobuild@v4.39.0 + uses: github/codeql-action/autobuild@v4.39.1 - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@v4.39.0 + uses: github/codeql-action/analyze@v4.39.1 with: category: /language:go From b9618df5514da502973077dcd7b401907d3f9017 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 03:52:37 +0000 Subject: [PATCH 2/5] chore(deps): bump golang.org/x/net from 0.61.0 to 0.62.0 Bumps [golang.org/x/net](https://github.com/golang/net) from 0.61.0 to 0.62.0. - [Commits](https://github.com/golang/net/compare/v0.61.0...v0.62.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.62.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d4d2fb7..e882de2 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/spf13/viper v1.21.0 go.etcd.io/bbolt v1.5.0 go.uber.org/zap v1.28.0 - golang.org/x/net v0.61.0 + golang.org/x/net v0.62.0 golang.org/x/text v0.43.0 gopkg.in/irc.v3 v3.1.4 ) diff --git a/go.sum b/go.sum index db4b956..78c45cc 100644 --- a/go.sum +++ b/go.sum @@ -59,8 +59,8 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= From f5c779465a542dec0d48e81b0c4b53f8a0802b8c Mon Sep 17 00:00:00 2001 From: ak Date: Fri, 6 Nov 2026 17:26:10 -0800 Subject: [PATCH 3/5] Honor website targets in status, ping, and uptime commands --- docs/plugins.md | 35 +++++- plugins/status.go | 53 +++++++- plugins/status_http.go | 245 ++++++++++++++++++++++++++++++++++++ plugins/status_http_test.go | 226 +++++++++++++++++++++++++++++++++ plugins/status_test.go | 129 ++++++++++++++++++- 5 files changed, 678 insertions(+), 10 deletions(-) create mode 100644 plugins/status_http.go create mode 100644 plugins/status_http_test.go diff --git a/docs/plugins.md b/docs/plugins.md index d6df944..1c68098 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -40,7 +40,7 @@ Plugins are enabled or disabled under plugins..enabled in config.yaml. - port: local bidirectional well-known port/service lookup - audit: package vulnerability discovery through OSV - docker: Docker Hub image metadata lookup -- status: local connection, uptime, and counter status +- status: public website HTTP status/response time, or local bot connection, uptime, and counters - xkcd: latest or numbered XKCD comics - lastfm: current or recently played Last.fm tracks - cats: short cat facts @@ -839,8 +839,37 @@ returns one cleaned and length-limited message, and does not download images. ## Status and uptime -These commands use only GoBot's local state and do not call an external -service: +With a hostname or HTTP(S) URL, these commands check that website from the +GoBot host, independently of the IRC network: + +~~~text +!status example.org +!status https://example.org/health +!ping example.org +!ping https://example.org/ +!uptime example.org +~~~ + +Bare hostnames default to HTTPS. Results include the HTTP status code and +elapsed HTTP response time (including connection setup and redirects), not +ICMP ping/round-trip time. HTTP 403/404/429 responses still mean the server +responded; they are reported accurately rather than called a connection +failure. DNS, TLS verification, timeout, and blocked-target failures are +reported separately. A single check cannot measure a remote site's historical +uptime, including when invoked as `!uptime `. + +Only public HTTP(S) targets on ports 80/443 are allowed. Credentials, private, +loopback, link-local, shared, reserved, and other special-purpose addresses +are blocked, including redirects and DNS answers containing a private address. +Connections are pinned to validated IPs; environment proxies are disabled and +TLS certificates are verified. Response headers and redirects are bounded, +page bodies are not read or parsed, and URL query parameters/fragments are not +echoed. Up to four checks can run concurrently across networks. Configure +`plugins.status.timeout_seconds` from 1 to 15 seconds (default 6); existing +command cooldowns also apply. + +Without a target, the commands retain their original local-only behavior and +do not call an external service: ~~~text !status diff --git a/plugins/status.go b/plugins/status.go index 9e7689e..0013e59 100644 --- a/plugins/status.go +++ b/plugins/status.go @@ -1,29 +1,70 @@ package plugins import ( + "context" "fmt" + "net/http" "strings" + "time" "github.com/variablenix/GoBot/bot" "github.com/variablenix/GoBot/storage" ) -// Status reports local process and connection state without making a network -// request. It is intentionally useful when external APIs are unavailable. -type Status struct{} +// Status reports local state without a target, or checks a public website when +// a host/URL is supplied. Website checks do not depend on the IRC network. +type Status struct { + client *http.Client + timeout time.Duration +} func (p *Status) Name() string { return "status" } func (p *Status) Commands() []string { return []string{"status", "uptime", "ping"} } func (p *Status) Help() string { - return "!status — show connection, uptime, and counters (aliases: !uptime, !ping)" + return "!status [host|http(s) URL] — website HTTP status and response time; without a target: bot connection, uptime, counters (aliases: !uptime, !ping; HTTP, not ICMP)" +} +func (p *Status) Init(cfg bot.PluginConfig, _ *storage.DB) error { + seconds := cfg.Int("timeout_seconds", 6) + if seconds < 1 || seconds > 15 { + seconds = 6 + } + p.timeout = time.Duration(seconds) * time.Second + p.client = newStatusHTTPClient(p.timeout) + return nil } -func (p *Status) Init(_ bot.PluginConfig, _ *storage.DB) error { return nil } func (p *Status) Handle(b *bot.Bot, m bot.Message) bool { - cmd, _, ok := bot.IsCommand(m, b.Config.CommandPrefix) + cmd, arg, ok := bot.IsCommand(m, b.Config.CommandPrefix) if !ok || !isStatusCommand(cmd) { return false } + if strings.TrimSpace(arg) != "" { + target, err := parseStatusTarget(arg) + if err != nil { + b.Send(m.ReplyTarget(), ircColor(ircYellow, "Use one public hostname or http(s) URL on port 80/443; private addresses and credentials are not allowed.")) + return true + } + select { + case statusCheckSlots <- struct{}{}: + defer func() { <-statusCheckSlots }() + default: + b.Send(m.ReplyTarget(), ircColor(ircYellow, "Website checks are busy; try again shortly.")) + return true + } + timeout := p.timeout + if timeout <= 0 { + timeout = 6 * time.Second + } + client := p.client + if client == nil { + client = newStatusHTTPClient(timeout) + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + result, err := checkStatusSite(ctx, client, target) + b.Send(m.ReplyTarget(), formatStatusSite(target, result, err)) + return true + } if b.Stats == nil { b.Send(m.ReplyTarget(), ircColor(ircYellow, "GoBot status is unavailable")) return true diff --git a/plugins/status_http.go b/plugins/status_http.go new file mode 100644 index 0000000..ec40b50 --- /dev/null +++ b/plugins/status_http.go @@ -0,0 +1,245 @@ +package plugins + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "time" + "unicode" + + "golang.org/x/net/idna" +) + +var ( + errStatusTargetBlocked = errors.New("non-public website target") + errStatusRedirectLimit = errors.New("website redirect limit reached") + // Shared across networks; user cooldowns also apply in the command router. + statusCheckSlots = make(chan struct{}, 4) +) + +func parseStatusTarget(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" || len(raw) > 2048 || strings.ContainsFunc(raw, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) { + return nil, errStatusTargetBlocked + } + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Opaque != "" || u.User != nil || u.Hostname() == "" { + return nil, errStatusTargetBlocked + } + port := u.Port() + if (port != "" && port != "80" && port != "443") || strings.HasSuffix(u.Host, ":") { + return nil, errStatusTargetBlocked + } + host := strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")) + if ip, err := netip.ParseAddr(host); err == nil { + if !statusPublicIP(ip) { + return nil, errStatusTargetBlocked + } + if ip.Is6() { + host = "[" + host + "]" + } + } else { + host, err = idna.Lookup.ToASCII(host) + if err != nil || len(host) > 253 || !strings.Contains(host, ".") { + return nil, errStatusTargetBlocked + } + for _, suffix := range []string{".localhost", ".local", ".internal", ".home.arpa"} { + if strings.HasSuffix(host, suffix) || host == strings.TrimPrefix(suffix, ".") { + return nil, errStatusTargetBlocked + } + } + labels := strings.Split(host, ".") + for _, label := range labels { + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' || strings.ContainsFunc(label, func(r rune) bool { + return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') + }) { + return nil, errStatusTargetBlocked + } + } + // Reject legacy shortened/octal IPv4 spellings, not just dotted quads. + if strings.Trim(labels[len(labels)-1], "0123456789") == "" { + return nil, errStatusTargetBlocked + } + } + u.Host = host + if port != "" { + u.Host += ":" + port + } + u.Fragment, u.RawFragment = "", "" + return u, nil +} + +// Restrict checks to ordinary globally routed addresses. In addition to local +// ranges, exclude shared, documentation, benchmark, and transition space from +// IANA's special-purpose registries (including IPv4-mapped private addresses). +var statusBlockedPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("192.0.0.0/24"), netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("192.88.99.0/24"), netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("198.51.100.0/24"), netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("240.0.0.0/4"), netip.MustParsePrefix("2001::/23"), + netip.MustParsePrefix("2001:db8::/32"), netip.MustParsePrefix("2002::/16"), + netip.MustParsePrefix("3fff::/20"), +} + +var statusIPv6Global = netip.MustParsePrefix("2000::/3") + +func statusPublicIP(ip netip.Addr) bool { + if !ip.IsValid() || ip.Zone() != "" { + return false + } + ip = ip.Unmap() + if !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.Is6() && !statusIPv6Global.Contains(ip) { + return false + } + for _, prefix := range statusBlockedPrefixes { + if prefix.Contains(ip) { + return false + } + } + return true +} + +func newStatusHTTPClient(timeout time.Duration) *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil // Never let an environment proxy bypass IP validation. + transport.DisableKeepAlives = true + transport.DisableCompression = true + transport.TLSHandshakeTimeout = 3 * time.Second + transport.ResponseHeaderTimeout = 4 * time.Second + transport.MaxResponseHeaderBytes = 64 << 10 + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + return dialStatusPublic(ctx, network, address, net.DefaultResolver.LookupNetIP, (&net.Dialer{Timeout: 2 * time.Second}).DialContext) + } + return &http.Client{Timeout: timeout, Transport: transport, CheckRedirect: func(req *http.Request, via []*http.Request) error { + if _, err := parseStatusTarget(req.URL.String()); err != nil { + return err + } + if len(via) >= 5 { + return errStatusRedirectLimit + } + return nil + }} +} + +func dialStatusPublic(ctx context.Context, network, address string, + lookup func(context.Context, string, string) ([]netip.Addr, error), + dial func(context.Context, string, string) (net.Conn, error), +) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil || (port != "80" && port != "443") { + return nil, errStatusTargetBlocked + } + var ips []netip.Addr + if ip, parseErr := netip.ParseAddr(host); parseErr == nil { + ips = []netip.Addr{ip} + } else { + ips, err = lookup(ctx, "ip", host) + if err != nil { + return nil, err + } + } + if len(ips) == 0 { + return nil, &net.DNSError{IsNotFound: true} + } + // Validate the entire answer before dialing even one address. Pin each + // connection to a validated IP; never resolve the hostname a second time. + for _, ip := range ips { + if !statusPublicIP(ip) { + return nil, errStatusTargetBlocked + } + } + for _, ip := range ips { + conn, dialErr := dial(ctx, network, net.JoinHostPort(ip.Unmap().String(), port)) + if dialErr == nil { + return conn, nil + } + err = dialErr + if ctx.Err() != nil { + break + } + } + return nil, err +} + +type statusSiteResult struct { + code int + elapsed time.Duration + finalURL *url.URL +} + +func checkStatusSite(ctx context.Context, client *http.Client, target *url.URL) (statusSiteResult, error) { + started := time.Now() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) + if err != nil { + return statusSiteResult{}, err + } + req.Header.Set("User-Agent", "GoBot/1.0 (public website availability check)") + res, err := client.Do(req) + result := statusSiteResult{elapsed: time.Since(started), finalURL: target} + if err != nil { + return result, err + } + defer res.Body.Close() // Only response headers are needed, never a page body. + result.code = res.StatusCode + if res.Request != nil { + result.finalURL = res.Request.URL + } + return result, nil +} + +func statusTargetLabel(target *url.URL, maxBytes int) string { + // Omit queries/fragments so tokens or other URL parameters are not echoed. + return truncateUTF8Bytes(cleanExternalText(target.Scheme+"://"+target.Host+target.EscapedPath()), maxBytes) +} + +func formatStatusSite(target *url.URL, result statusSiteResult, err error) string { + label := "[Status] " + statusTargetLabel(target, 180) + if err != nil { + return label + " | " + ircColor(ircYellow, statusSiteError(err)) + } + color := ircGreen + if result.code >= 400 { + color = ircYellow + } + if result.code >= 500 { + color = ircRed + } + text := fmt.Sprintf("%s | reachable | %s | HTTP response %d ms", label, ircColor(color, fmt.Sprintf("HTTP %d %s", result.code, http.StatusText(result.code))), result.elapsed.Milliseconds()) + if result.finalURL != nil && result.finalURL.String() != target.String() { + text += " | redirected to " + statusTargetLabel(result.finalURL, 100) + } + return text +} + +func statusSiteError(err error) string { + var dnsErr *net.DNSError + var certErr *tls.CertificateVerificationError + var unknownCA x509.UnknownAuthorityError + var hostnameErr x509.HostnameError + var netErr net.Error + switch { + case errors.Is(err, errStatusTargetBlocked): + return "blocked non-public target or redirect" + case errors.Is(err, errStatusRedirectLimit): + return "too many redirects" + case errors.Is(err, context.DeadlineExceeded) || errors.As(err, &netErr) && netErr.Timeout(): + return "website check timed out" + case errors.As(err, &dnsErr): + return "DNS lookup failed" + case errors.As(err, &certErr) || errors.As(err, &unknownCA) || errors.As(err, &hostnameErr): + return "TLS certificate verification failed" + default: + return "HTTP connection failed" + } +} diff --git a/plugins/status_http_test.go b/plugins/status_http_test.go new file mode 100644 index 0000000..e2a3e56 --- /dev/null +++ b/plugins/status_http_test.go @@ -0,0 +1,226 @@ +package plugins + +import ( + "context" + "crypto/x509" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/variablenix/GoBot/bot" +) + +func TestStatusTargetParsing(t *testing.T) { + for raw, want := range map[string]string{ + "example.org": "https://example.org", "https://EXAMPLE.org/health?q=1#section": "https://example.org/health?q=1", + "http://example.org:80/a": "http://example.org:80/a", "example.org.:443": "https://example.org:443", + "8.8.8.8": "https://8.8.8.8", "[2606:4700:4700::1111]": "https://[2606:4700:4700::1111]", + "https://bücher.de/": "https://xn--bcher-kva.de/", + } { + parsed, err := parseStatusTarget(raw) + if err != nil || parsed.String() != want { + t.Errorf("parseStatusTarget(%q) = %v, %v; want %s", raw, parsed, err, want) + } + } + for _, raw := range []string{ + "", "localhost", "http://localhost./", "host.local", "host.internal", "home.arpa", "host.home.arpa", + "http://user:secret@example.org", "ftp://example.org", "file:///etc/passwd", "https://example.org:22", "example.org:", + "https://example.org other.org", "https://example.org/\r\nInjected: yes", "https://example.org/\x00", "https://example.org/%zz", + "https://-invalid.example", "https://invalid_.example", "https://example..org", "https://" + strings.Repeat("a", 64) + ".org", + "2130706433", "127.1", "0177.0.0.1", "0x7f000001", "http://[fe80::1%25eth0]/", strings.Repeat("a", 2049), + } { + if _, err := parseStatusTarget(raw); err == nil { + t.Errorf("unsafe target accepted: %q", raw) + } + } +} + +func TestStatusPublicIP(t *testing.T) { + for _, raw := range []string{ + "0.0.0.0", "0.1.2.3", "127.0.0.1", "10.1.2.3", "172.16.0.1", "192.168.1.1", "169.254.169.254", + "100.64.0.1", "100.100.100.200", "192.0.0.1", "192.0.2.1", "192.88.99.1", "198.18.0.1", "198.51.100.1", "203.0.113.1", + "224.0.0.1", "240.0.0.1", "255.255.255.255", "::", "::1", "fc00::1", "fe80::1", "ff02::1", "::ffff:127.0.0.1", + "::ffff:10.0.0.1", "64:ff9b::a00:1", "2001::1", "2001:db8::1", "2002:7f00:1::1", "3fff::1", + } { + ip := netip.MustParseAddr(raw) + if statusPublicIP(ip) { + t.Errorf("non-public address accepted: %s", raw) + } + if _, err := parseStatusTarget("https://" + net.JoinHostPort(raw, "443")); err == nil { + t.Errorf("non-public literal accepted: %s", raw) + } + } + for _, raw := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111", "::ffff:8.8.8.8"} { + if !statusPublicIP(netip.MustParseAddr(raw)) { + t.Errorf("public address rejected: %s", raw) + } + } +} + +func TestStatusDNSRejectsAllMixedAndPrivateAnswersBeforeDial(t *testing.T) { + for _, addresses := range [][]netip.Addr{ + {netip.MustParseAddr("127.0.0.1")}, {netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("10.0.0.1")}, + {netip.MustParseAddr("::ffff:192.168.1.1")}, {netip.MustParseAddr("100.100.100.200")}, + } { + _, err := dialStatusPublic(t.Context(), "tcp", "example.org:443", func(context.Context, string, string) ([]netip.Addr, error) { + return addresses, nil + }, func(context.Context, string, string) (net.Conn, error) { + t.Fatal("private or mixed DNS answer was dialed") + return nil, nil + }) + if !errors.Is(err, errStatusTargetBlocked) { + t.Fatalf("private DNS answer was not blocked: %v", err) + } + } +} + +func TestStatusDNSPinnedAndPublicAddressFailover(t *testing.T) { + lookups, dials := 0, 0 + left, right := net.Pipe() + defer left.Close() + defer right.Close() + conn, err := dialStatusPublic(t.Context(), "tcp", "example.org:443", func(context.Context, string, string) ([]netip.Addr, error) { + lookups++ + if lookups > 1 { + return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil + } + return []netip.Addr{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("8.8.8.8")}, nil + }, func(_ context.Context, network, address string) (net.Conn, error) { + dials++ + if network != "tcp" || address != []string{"1.1.1.1:443", "8.8.8.8:443"}[dials-1] { + t.Fatalf("connection not pinned to a validated address: %s", address) + } + if dials == 1 { + return nil, errors.New("synthetic connection failure") + } + return left, nil + }) + if err != nil || conn != left || lookups != 1 || dials != 2 { + t.Fatalf("public DNS pinning/failover failed: lookups=%d dials=%d err=%v", lookups, dials, err) + } +} + +func TestStatusClientProxyTLSAndRedirectPolicy(t *testing.T) { + t.Setenv("HTTPS_PROXY", "http://127.0.0.1:9999") + client := newStatusHTTPClient(time.Second) + transport := client.Transport.(*http.Transport) + if transport.Proxy != nil || transport.TLSClientConfig != nil && transport.TLSClientConfig.InsecureSkipVerify || !transport.DisableKeepAlives || transport.MaxResponseHeaderBytes != 64<<10 { + t.Fatal("website client bypasses protections or resource bounds") + } + for _, destination := range []string{"http://127.0.0.1/", "https://[::ffff:10.0.0.1]/", "https://example.org:22/", "http://user:secret@example.org/"} { + calls := 0 + copy := *client + copy.Transport = newPluginRoundTripper(func(*http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: http.StatusFound, Header: http.Header{"Location": []string{destination}}, Body: io.NopCloser(strings.NewReader(""))}, nil + }) + target, _ := parseStatusTarget("example.org") + _, err := checkStatusSite(t.Context(), ©, target) + if !errors.Is(err, errStatusTargetBlocked) || calls != 1 { + t.Fatalf("unsafe redirect followed: calls=%d err=%v", calls, err) + } + } + request, _ := http.NewRequest(http.MethodGet, "https://example.org/", nil) + if !errors.Is(client.CheckRedirect(request, make([]*http.Request, 5)), errStatusRedirectLimit) { + t.Fatal("redirect loop was not bounded") + } +} + +type statusUnreadBody struct{ closed bool } + +func (*statusUnreadBody) Read([]byte) (int, error) { panic("website check read an unnecessary body") } +func (b *statusUnreadBody) Close() error { b.closed = true; return nil } + +func TestStatusHTTPResponsesAndBodyBounds(t *testing.T) { + for _, code := range []int{200, 204, 301, 401, 403, 404, 429, 500, 503} { + body := &statusUnreadBody{} + client := &http.Client{Transport: newPluginRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: code, Header: make(http.Header), Body: body}, nil + })} + target, _ := parseStatusTarget("example.org/health?secret=do-not-echo") + result, err := checkStatusSite(t.Context(), client, target) + text := stripPluginIRC(formatStatusSite(target, result, err)) + if err != nil || result.code != code || !body.closed || !strings.Contains(text, "reachable") || !strings.Contains(text, http.StatusText(code)) || strings.Contains(text, "do-not-echo") { + t.Fatalf("HTTP %d was hidden or mislabeled: %s err=%v", code, text, err) + } + } +} + +func TestStatusTimeoutAndSafeErrors(t *testing.T) { + client := &http.Client{Transport: newPluginRoundTripper(func(req *http.Request) (*http.Response, error) { + <-req.Context().Done() + return nil, req.Context().Err() + })} + p := &Status{client: client, timeout: 30 * time.Millisecond} + started := time.Now() + reply := statusTestReply(t, p, "!ping example.org", "alpha", nil) + if !strings.Contains(reply, "timed out") || time.Since(started) > time.Second { + t.Fatalf("slow website check was not bounded: %s", reply) + } + for _, test := range []struct { + err error + want string + }{ + {&net.DNSError{Err: "private resolver diagnostic", Name: "secret.internal"}, "DNS lookup failed"}, + {x509.UnknownAuthorityError{}, "TLS certificate verification failed"}, + {errStatusTargetBlocked, "blocked non-public target or redirect"}, + {errStatusRedirectLimit, "too many redirects"}, + {errors.New("secret internal connection details"), "HTTP connection failed"}, + } { + if got := statusSiteError(&url.Error{Op: "Get", URL: "https://example.org/?secret", Err: test.err}); got != test.want { + t.Errorf("unsafe or incorrect error: got %q want %q", got, test.want) + } + } +} + +func TestStatusLongRedirectReplyIsBounded(t *testing.T) { + initial, _ := parseStatusTarget("https://example.org/" + strings.Repeat("%E6%97%A5", 120) + "?secret=initial") + final, _ := parseStatusTarget("https://other.example/" + strings.Repeat("a", 500) + "?secret=final") + p := &Status{client: &http.Client{Transport: newPluginRoundTripper(func(*http.Request) (*http.Response, error) { + response := newPluginResponse(http.StatusServiceUnavailable, "") + response.Request = &http.Request{URL: final} + return response, nil + })}} + reply := statusTestReply(t, p, "!status "+initial.String(), "alpha", bot.NewStats()) + if !strings.Contains(reply, "HTTP 503") || !strings.Contains(reply, "redirected to") || strings.Contains(reply, "secret=") { + t.Fatalf("long redirected response lost its status or leaked query: %s", reply) + } +} + +// Opt-in deployment-host check through the command handlers, without an IRC +// connection, API credentials, or logging target URLs/query parameters. +func TestLiveStatusSites(t *testing.T) { + if os.Getenv("GOBOT_LIVE_STATUS") != "1" { + t.Skip("opt-in public website status check") + } + targets := []string{"example.org", "https://example.org/", "https://www.wikipedia.org/"} + if raw := os.Getenv("GOBOT_LIVE_STATUS_TARGETS"); raw != "" { + if json.Unmarshal([]byte(raw), &targets) != nil || len(targets) == 0 || len(targets) > 8 { + t.Fatal("invalid live target list") + } + } + p := &Status{} + if err := p.Init(bot.PluginConfig{}, nil); err != nil { + t.Fatal(err) + } + for i, target := range targets { + t.Run("target-"+strconv.Itoa(i+1), func(t *testing.T) { + started := time.Now() + command := []string{"status", "ping", "uptime"}[i%3] + reply := statusTestReply(t, p, "!"+command+" "+target, "test-network", nil) + if !strings.Contains(reply, "reachable | HTTP ") || !strings.Contains(reply, "HTTP response") || strings.Contains(reply, "GoBot:") { + t.Fatal("website check did not report an HTTP response") + } + t.Logf("command=%s returned_http_response=true elapsed_ms=%d", command, time.Since(started).Milliseconds()) + }) + } +} diff --git a/plugins/status_test.go b/plugins/status_test.go index c42b64f..b669517 100644 --- a/plugins/status_test.go +++ b/plugins/status_test.go @@ -1,6 +1,15 @@ package plugins -import "testing" +import ( + "context" + "net/http" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/variablenix/GoBot/bot" +) func TestStatusCommands(t *testing.T) { plugin := &Status{} @@ -13,3 +22,121 @@ func TestStatusCommands(t *testing.T) { } } } + +func statusTestReply(t *testing.T, p *Status, command, network string, stats *bot.Stats) string { + t.Helper() + sent := make(chan string, 4) + b := &bot.Bot{Config: bot.Config{CommandPrefix: "!", NetworkName: network}, Stats: stats, + Queue: bot.NewQueue(1000, 4, func(out bot.Outgoing) { + if out.Target != "#test" { + t.Error("status reply sent to the wrong target") + } + sent <- out.Text + })} + t.Cleanup(func() { b.Queue.Drain(context.Background()) }) + if !p.Handle(b, bot.Message{Command: "PRIVMSG", Nick: "tester", Target: "#test", IsChannel: true, Text: command}) { + t.Fatal("status command was not handled") + } + b.Queue.Drain(context.Background()) + if len(sent) != 1 { + t.Fatalf("wanted one reply, got %d", len(sent)) + } + reply := <-sent + if !utf8.ValidString(reply) || strings.ContainsAny(reply, "\r\n\x00\uFE0F") || len("PRIVMSG #test :"+reply+"\r\n") > 512 { + t.Fatal("unsafe or oversized IRC reply") + } + return stripPluginIRC(reply) +} + +func TestStatusTargetCommandsUseWebsiteNotIRCNetwork(t *testing.T) { + for _, command := range []string{"status", "ping", "uptime"} { + for _, network := range []string{"alpha", "beta"} { + for _, target := range []string{"example.org", "https://example.org/health?token=synthetic-secret", "http://example.org/"} { + t.Run(command+"/"+network+"/"+target, func(t *testing.T) { + calls := 0 + p := &Status{client: &http.Client{Transport: newPluginRoundTripper(func(req *http.Request) (*http.Response, error) { + calls++ + if req.URL.Host != "example.org" || req.Method != http.MethodGet || req.Context().Err() != nil { + t.Fatal("website request did not use the supplied target") + } + return newPluginResponse(http.StatusOK, ""), nil + })}} + // Target checks must work even when local bot stats are absent. + reply := statusTestReply(t, p, "!"+command+" "+target, network, nil) + if calls != 1 || !strings.Contains(reply, "example.org") || !strings.Contains(reply, "HTTP 200 OK") || !strings.Contains(reply, "HTTP response") || strings.Contains(reply, "GoBot:") || strings.Contains(reply, "synthetic-secret") { + t.Fatalf("target was ignored or sensitive query echoed: %s", reply) + } + }) + } + } + } +} + +func TestStatusNoTargetPreservesLocalBehavior(t *testing.T) { + p := &Status{client: &http.Client{Transport: newPluginRoundTripper(func(*http.Request) (*http.Response, error) { + t.Fatal("no-target command made an external request") + return nil, context.Canceled + })}} + for _, command := range []string{"!status", "!ping", "!uptime"} { + for _, network := range []string{"alpha", "beta"} { + reply := statusTestReply(t, p, command, network, bot.NewStats()) + if !strings.Contains(reply, "GoBot:") || !strings.Contains(reply, "network "+network) || !strings.Contains(reply, "uptime") { + t.Fatalf("local status regressed: %s", reply) + } + } + } + if reply := statusTestReply(t, p, "!status", "alpha", nil); reply != "GoBot status is unavailable" { + t.Fatalf("missing local stats behavior changed: %s", reply) + } +} + +func TestStatusTargetInvalidNeverFallsBackToBotStats(t *testing.T) { + p := &Status{client: &http.Client{Transport: newPluginRoundTripper(func(*http.Request) (*http.Response, error) { + t.Fatal("invalid target reached the network") + return nil, context.Canceled + })}} + for _, target := range []string{"127.0.0.1", "http://localhost", "example.org other.example", "http://user:secret@example.org/", "example.org:22"} { + reply := statusTestReply(t, p, "!status "+target, "alpha", bot.NewStats()) + if !strings.Contains(reply, "public hostname") || strings.Contains(reply, "GoBot:") || strings.Contains(reply, "secret") { + t.Fatalf("invalid target silently became local status: %s", reply) + } + } +} + +func TestStatusBusyDoesNotBlockLocalStatus(t *testing.T) { + for i := 0; i < cap(statusCheckSlots); i++ { + statusCheckSlots <- struct{}{} + } + t.Cleanup(func() { + for i := 0; i < cap(statusCheckSlots); i++ { + <-statusCheckSlots + } + }) + if reply := statusTestReply(t, &Status{}, "!ping example.org", "alpha", nil); !strings.Contains(reply, "busy") { + t.Fatalf("saturated checks were not bounded: %s", reply) + } + if reply := statusTestReply(t, &Status{}, "!status", "alpha", bot.NewStats()); !strings.Contains(reply, "GoBot:") { + t.Fatalf("website saturation blocked local status: %s", reply) + } +} + +func TestStatusHelpAndTimeout(t *testing.T) { + for _, seconds := range []int{-1, 0, 1, 6, 15, 16} { + p := &Status{} + if err := p.Init(bot.PluginConfig{"timeout_seconds": seconds}, nil); err != nil { + t.Fatal(err) + } + want := seconds + if want < 1 || want > 15 { + want = 6 + } + if p.timeout != time.Duration(want)*time.Second || p.client.Timeout != p.timeout { + t.Fatal("status timeout was not bounded") + } + for _, text := range []string{"host", "URL", "HTTP", "without a target", "!uptime", "!ping", "not ICMP"} { + if !strings.Contains(p.Help(), text) { + t.Fatalf("help does not explain %q", text) + } + } + } +} From 18895993e3bd970ceb9715458dca95cca44673c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:29:01 +0000 Subject: [PATCH 4/5] chore(deps): bump github.com/agnivade/levenshtein from 1.2.1 to 1.2.2 Bumps [github.com/agnivade/levenshtein](https://github.com/agnivade/levenshtein) from 1.2.1 to 1.2.2. - [Commits](https://github.com/agnivade/levenshtein/compare/v1.2.1...v1.2.2) --- updated-dependencies: - dependency-name: github.com/agnivade/levenshtein dependency-version: 1.2.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e882de2..d05c547 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/variablenix/GoBot go 1.26.10 require ( - github.com/agnivade/levenshtein v1.2.1 + github.com/agnivade/levenshtein v1.2.2 github.com/chromedp/chromedp v0.21.0 github.com/spf13/viper v1.21.0 go.etcd.io/bbolt v1.5.0 diff --git a/go.sum b/go.sum index 78c45cc..cc27a5c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= -github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/agnivade/levenshtein v1.2.2 h1:fI8RgiztYX1Nn5pBHHqQ9EMCCklhrmXqPRIedFmx5M4= +github.com/agnivade/levenshtein v1.2.2/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/chromedp/cdproto v0.157.8 h1:Tkq0AjsUlC3Z4sJfHBzV/uAAxnTF/AZyuSTjfV/1gvk= From 1b05633daf8879ca754773002ddd0ddb9cb65fbe Mon Sep 17 00:00:00 2001 From: ak Date: Fri, 6 Nov 2026 17:31:18 -0800 Subject: [PATCH 5/5] Align status configuration guide and public-target help --- docs/configuration.md | 11 ++++++++++- plugins/status.go | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index d2290d2..b1e7a68 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -163,7 +163,16 @@ plugins: docker: {enabled: true, timeout_seconds: 8, max_length: 300} ``` -`status`, `calc`, `foods`, `sports`, `car`, and `weapons` are local. The +`calc`, `foods`, `sports`, `car`, and `weapons` are local. Without a target, +`status` (and its `uptime`/`ping` aliases) also uses only local bot state. +With a public hostname or HTTP(S) URL it checks that website's HTTP status +and response time on port 80/443, independently of the IRC network. Website +checks default to a six-second timeout; `plugins.status.timeout_seconds` +accepts 1–15 seconds. They are not ICMP probes or historical uptime monitors. +See [Status and uptime](plugins.md#status-and-uptime) for examples and safety +limits. + +The `ask` plugin uses DuckDuckGo Search Assist first when `search_assist_enabled` is true. This is a best-effort web integration that first uses the lightweight Search Assist request and then, when enabled, a local Chromium/Chrome browser to read diff --git a/plugins/status.go b/plugins/status.go index 0013e59..60a3087 100644 --- a/plugins/status.go +++ b/plugins/status.go @@ -21,7 +21,7 @@ type Status struct { func (p *Status) Name() string { return "status" } func (p *Status) Commands() []string { return []string{"status", "uptime", "ping"} } func (p *Status) Help() string { - return "!status [host|http(s) URL] — website HTTP status and response time; without a target: bot connection, uptime, counters (aliases: !uptime, !ping; HTTP, not ICMP)" + return "!status [public host|http(s) URL] — HTTP status and response time (ports 80/443); without a target: bot connection, uptime, counters (aliases: !uptime, !ping; HTTP, not ICMP)" } func (p *Status) Init(cfg bot.PluginConfig, _ *storage.DB) error { seconds := cfg.Int("timeout_seconds", 6)