diff --git a/internal/modules/webmail_fingerprint_test.go b/internal/modules/webmail_fingerprint_test.go new file mode 100644 index 0000000..fdd7584 --- /dev/null +++ b/internal/modules/webmail_fingerprint_test.go @@ -0,0 +1,190 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/vmfunc/sif/internal/modules" +) + +func runWebmailModule(t *testing.T, file string, status int, headers map[string]string, body string) *modules.Result { + t.Helper() + def, err := modules.ParseYAMLModule(file) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for k, v := range headers { + w.Header().Set(k, v) + } + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{ + Timeout: 5 * time.Second, + Threads: 2, + }) + if err != nil { + t.Fatalf("execute %s: %v", file, err) + } + return res +} + +func webmailExtract(res *modules.Result, key string) string { + for _, f := range res.Findings { + if v := f.Extracted[key]; v != "" { + return v + } + } + return "" +} + +// TestRoundcubeWebmailModule validates the roundcube-webmail fingerprint against a +// realistic rendering of the elastic skin login.html (skins/elastic/templates/login.html +// plus program/include/rcmail_output_html.php's `var rcmail = new rcube_webmail();` head_top +// bootstrap). display_product_info defaults to 1 (name only, no version), so the +// version-bearing sample models an admin who bumped it to 2. +func TestRoundcubeWebmailModule(t *testing.T) { + const rc = "../../modules/info/roundcube-webmail.yaml" + + nameOnlyBody := `Roundcube Webmail :: Welcome to Roundcube Webmail + + +

Roundcube Webmail Login

+
+
+ +
` + + versionBody := `Roundcube Webmail :: Welcome to Roundcube Webmail + + +

Roundcube Webmail Login

+
+
+ +
` + + t.Run("default install with display_product_info=1 fingerprints without a version", func(t *testing.T) { + res := runWebmailModule(t, rc, 200, nil, nameOnlyBody) + if len(res.Findings) == 0 { + t.Fatal("expected a roundcube finding") + } + if v := webmailExtract(res, "roundcube_version"); v != "" { + t.Errorf("roundcube_version=%q, want empty (display_product_info=1 hides it)", v) + } + }) + + t.Run("display_product_info=2 also yields the version", func(t *testing.T) { + res := runWebmailModule(t, rc, 200, nil, versionBody) + if len(res.Findings) == 0 { + t.Fatal("expected a roundcube finding") + } + if v := webmailExtract(res, "roundcube_version"); v != "1.6.9" { + t.Errorf("roundcube_version=%q, want 1.6.9", v) + } + }) + + t.Run("a login page served through a cdn that strips Set-Cookie still fires", func(t *testing.T) { + // the body pair is product-unique on its own; the fingerprint must not depend on a + // session cookie a caching reverse-proxy commonly drops on cacheable responses. + res := runWebmailModule(t, rc, 200, nil, nameOnlyBody) + if len(res.Findings) == 0 { + t.Fatal("expected a roundcube finding without any Set-Cookie header") + } + }) + + t.Run("prose mentioning Roundcube Webmail without the app markers is not flagged", func(t *testing.T) { + body := `

Switching from Roundcube Webmail to something else

+

We migrated off Roundcube Webmail last year after evaluating alternatives.

` + if res := runWebmailModule(t, rc, 200, nil, body); len(res.Findings) > 0 { + t.Errorf("a blog post mentioning roundcube should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a plain 200 is not a leak", func(t *testing.T) { + if res := runWebmailModule(t, rc, 200, nil, "ok"); len(res.Findings) > 0 { + t.Errorf("a plain 200 body should not match, got %d findings", len(res.Findings)) + } + }) +} + +// TestZimbraWebmailModule validates the zimbra-webmail fingerprint against the real, +// unauthenticated zm-web-client static asset WebRoot/js/zimbraMail/share/model/ZmSettings.js, +// whose license header literally reads "Zimbra Collaboration Suite Web Client" and whose +// CLIENT_VERSION registerSetting call carries the build's version string in production +// (the source repo ships the unbuilt "@buildVersion@" placeholder). +func TestZimbraWebmailModule(t *testing.T) { + const zm = "../../modules/info/zimbra-webmail.yaml" + + builtBody := `/* + * ***** BEGIN LICENSE BLOCK ***** + * Zimbra Collaboration Suite Web Client + * Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016 Synacor, Inc. + */ + this.registerSetting("BRANCH", {type:ZmSetting.T_CONFIG, defaultValue:"JUDASPRIEST"}); + this.registerSetting("CLIENT_DATETIME", {type:ZmSetting.T_CONFIG, defaultValue:"04/17/2024 12:00:00"}); + this.registerSetting("CLIENT_RELEASE", {type:ZmSetting.T_CONFIG, defaultValue:"GA"}); + this.registerSetting("CLIENT_VERSION", {type:ZmSetting.T_CONFIG, defaultValue:"8.8.12_GA_3844"}); + this.registerSetting("CONFIG_PATH", {type:ZmSetting.T_CONFIG, defaultValue:appContextPath + "/js/zimbraMail/config"}); +` + + unbuiltBody := `/* + * ***** BEGIN LICENSE BLOCK ***** + * Zimbra Collaboration Suite Web Client + */ + this.registerSetting("CLIENT_VERSION", {type:ZmSetting.T_CONFIG, defaultValue:"@buildVersion@"}); +` + + t.Run("a built zm-web-client asset is flagged with its build version", func(t *testing.T) { + res := runWebmailModule(t, zm, 200, map[string]string{"Content-Type": "application/x-javascript"}, builtBody) + if len(res.Findings) == 0 { + t.Fatal("expected a zimbra finding") + } + if v := webmailExtract(res, "zimbra_version"); v != "8.8.12_GA_3844" { + t.Errorf("zimbra_version=%q, want 8.8.12_GA_3844", v) + } + }) + + t.Run("an unbuilt source checkout fingerprints without a usable version", func(t *testing.T) { + res := runWebmailModule(t, zm, 200, nil, unbuiltBody) + if len(res.Findings) == 0 { + t.Fatal("expected a zimbra finding even without a real build stamp") + } + if v := webmailExtract(res, "zimbra_version"); v != "" { + t.Errorf("zimbra_version=%q, want empty (placeholder build value must not be captured)", v) + } + }) + + t.Run("a sibling settings file mentioning CLIENT_VERSION alone is not flagged", func(t *testing.T) { + // shares the CLIENT_VERSION token but not the Zimbra product string: proves the + // product-string anchor, not the generic config key, is load-bearing. + body := `this.registerSetting("CLIENT_VERSION", {type:Setting.T_CONFIG, defaultValue:"1.0.0"});` + if res := runWebmailModule(t, zm, 200, nil, body); len(res.Findings) > 0 { + t.Errorf("a generic settings file should not match zimbra, got %d findings", len(res.Findings)) + } + }) + + t.Run("a 404 is not a leak", func(t *testing.T) { + if res := runWebmailModule(t, zm, 404, nil, "not found"); len(res.Findings) > 0 { + t.Errorf("a 404 should not match, got %d findings", len(res.Findings)) + } + }) +} diff --git a/modules/info/roundcube-webmail.yaml b/modules/info/roundcube-webmail.yaml new file mode 100644 index 0000000..0764ecb --- /dev/null +++ b/modules/info/roundcube-webmail.yaml @@ -0,0 +1,36 @@ +# Roundcube Webmail Detection Module + +id: roundcube-webmail +info: + name: Roundcube Webmail + author: sif + severity: info + description: Detects a reachable Roundcube webmail login interface via its product string and rcmail JS bootstrap, and extracts the version when the admin has enabled its display + tags: [roundcube, webmail, groupware, mail, detection, info] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/?_task=login" + + matchers: + - type: word + part: body + words: + - "Roundcube Webmail" + - "new rcube_webmail()" + condition: and + + - type: status + status: + - 200 + + extractors: + - type: regex + name: roundcube_version + part: body + regex: + - 'Roundcube Webmail\s+([0-9]+\.[0-9]+(?:\.[0-9]+)?)' + group: 1 diff --git a/modules/info/zimbra-webmail.yaml b/modules/info/zimbra-webmail.yaml new file mode 100644 index 0000000..a0d613a --- /dev/null +++ b/modules/info/zimbra-webmail.yaml @@ -0,0 +1,36 @@ +# Zimbra Collaboration Suite Webmail Detection Module + +id: zimbra-webmail +info: + name: Zimbra Collaboration Suite Webmail + author: sif + severity: info + description: Detects a Zimbra Collaboration Suite webmail install by requesting the unauthenticated static ZmSettings.js client bootstrap and extracts the deployed CLIENT_VERSION build string + tags: [zimbra, webmail, groupware, mail, detection, info] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/js/zimbraMail/share/model/ZmSettings.js" + + matchers: + - type: word + part: body + words: + - "Zimbra Collaboration Suite Web Client" + - "CLIENT_VERSION" + condition: and + + - type: status + status: + - 200 + + extractors: + - type: regex + name: zimbra_version + part: body + regex: + - '"CLIENT_VERSION"\s*,\s*\{[^}]*defaultValue:"([^"@][^"]*)"' + group: 1