Skip to content

Commit 49e41e4

Browse files
authored
feat(modules): add vaultwarden, authentik and synapse version modules (#295)
1 parent 2a88881 commit 49e41e4

6 files changed

Lines changed: 387 additions & 0 deletions
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package modules_test
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
"time"
9+
10+
"github.com/vmfunc/sif/internal/modules"
11+
)
12+
13+
func runAuthentikModule(t *testing.T, status int, body string) *modules.Result {
14+
t.Helper()
15+
def, err := modules.ParseYAMLModule("../../modules/recon/authentik-version-exposure.yaml")
16+
if err != nil {
17+
t.Fatalf("parse authentik module: %v", err)
18+
}
19+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
20+
w.WriteHeader(status)
21+
_, _ = w.Write([]byte(body))
22+
}))
23+
defer srv.Close()
24+
25+
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
26+
Timeout: 5 * time.Second,
27+
Threads: 2,
28+
})
29+
if err != nil {
30+
t.Fatalf("execute authentik module: %v", err)
31+
}
32+
return res
33+
}
34+
35+
func authentikExtract(res *modules.Result, key string) string {
36+
for _, f := range res.Findings {
37+
if v := f.Extracted[key]; v != "" {
38+
return v
39+
}
40+
}
41+
return ""
42+
}
43+
44+
func TestAuthentikVersionExposureModule(t *testing.T) {
45+
// shape taken from authentik/core/templates/base/header_js.html, rendered
46+
// unauthenticated on the default authentication flow page
47+
authentikBody := `<html><head><title>authentik</title></head><body>
48+
<script data-id="authentik-config">
49+
"use strict";
50+
window.authentik = {
51+
locale: "en",
52+
config: JSON.parse('{}'),
53+
brand: JSON.parse('{}'),
54+
versionFamily: "2025.12",
55+
versionSubdomain: "version-2025-12",
56+
build: "abc123",
57+
api: { base: "/", relBase: "/" },
58+
};
59+
</script>
60+
</body></html>`
61+
62+
t.Run("an exposed authentik login flow page is flagged and versioned", func(t *testing.T) {
63+
res := runAuthentikModule(t, 200, authentikBody)
64+
if len(res.Findings) == 0 {
65+
t.Fatal("expected an authentik finding")
66+
}
67+
if v := authentikExtract(res, "authentik_version_family"); v != "2025.12" {
68+
t.Errorf("authentik_version_family=%q, want 2025.12", v)
69+
}
70+
})
71+
72+
t.Run("a blog post mentioning authentik is not flagged", func(t *testing.T) {
73+
body := `<html><body><h1>Migrating to authentik for SSO</h1>
74+
<p>We recently switched our identity provider to authentik and could not be happier.</p>
75+
</body></html>`
76+
if res := runAuthentikModule(t, 200, body); len(res.Findings) > 0 {
77+
t.Errorf("prose mentioning authentik should not match, got %d findings", len(res.Findings))
78+
}
79+
})
80+
81+
t.Run("a generic sso login page with a version field is not authentik", func(t *testing.T) {
82+
body := `<html><body><script>window.myApp = { versionFamily: "9.9" };</script></body></html>`
83+
if res := runAuthentikModule(t, 200, body); len(res.Findings) > 0 {
84+
t.Errorf("a generic sso page should not match, got %d findings", len(res.Findings))
85+
}
86+
})
87+
88+
t.Run("a 404 is not a leak", func(t *testing.T) {
89+
if res := runAuthentikModule(t, 404, "not found"); len(res.Findings) > 0 {
90+
t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
91+
}
92+
})
93+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package modules_test
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
"time"
9+
10+
"github.com/vmfunc/sif/internal/modules"
11+
)
12+
13+
func runSynapseModule(t *testing.T, status int, body string) *modules.Result {
14+
t.Helper()
15+
def, err := modules.ParseYAMLModule("../../modules/recon/synapse-federation-version-exposure.yaml")
16+
if err != nil {
17+
t.Fatalf("parse synapse module: %v", err)
18+
}
19+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
20+
w.WriteHeader(status)
21+
_, _ = w.Write([]byte(body))
22+
}))
23+
defer srv.Close()
24+
25+
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
26+
Timeout: 5 * time.Second,
27+
Threads: 2,
28+
})
29+
if err != nil {
30+
t.Fatalf("execute synapse module: %v", err)
31+
}
32+
return res
33+
}
34+
35+
func synapseExtract(res *modules.Result, key string) string {
36+
for _, f := range res.Findings {
37+
if v := f.Extracted[key]; v != "" {
38+
return v
39+
}
40+
}
41+
return ""
42+
}
43+
44+
func TestSynapseFederationVersionExposureModule(t *testing.T) {
45+
// shape from element-hq/synapse FederationVersionServlet.on_GET, the
46+
// matrix server-server federation version endpoint (unauthenticated by spec)
47+
synapseBody := `{"server": {"name": "Synapse", "version": "1.99.0"}}`
48+
49+
t.Run("an exposed synapse federation version endpoint is flagged and versioned", func(t *testing.T) {
50+
res := runSynapseModule(t, 200, synapseBody)
51+
if len(res.Findings) == 0 {
52+
t.Fatal("expected a synapse finding")
53+
}
54+
if v := synapseExtract(res, "synapse_version"); v != "1.99.0" {
55+
t.Errorf("synapse_version=%q, want 1.99.0", v)
56+
}
57+
})
58+
59+
t.Run("a dendrite homeserver on the same federation endpoint is not flagged as synapse", func(t *testing.T) {
60+
// dendrite implements the same open matrix federation version api
61+
// with the same json shape but its own implementation name
62+
body := `{"server": {"name": "Dendrite", "version": "0.13.7"}}`
63+
if res := runSynapseModule(t, 200, body); len(res.Findings) > 0 {
64+
t.Errorf("a dendrite homeserver should not match synapse, got %d findings", len(res.Findings))
65+
}
66+
})
67+
68+
t.Run("a conduit homeserver on the same federation endpoint is not flagged as synapse", func(t *testing.T) {
69+
body := `{"server": {"name": "Conduit", "version": "0.9.0"}}`
70+
if res := runSynapseModule(t, 200, body); len(res.Findings) > 0 {
71+
t.Errorf("a conduit homeserver should not match synapse, got %d findings", len(res.Findings))
72+
}
73+
})
74+
75+
t.Run("version still extracts when json keys are reordered", func(t *testing.T) {
76+
// json object member order is not significant; the extractor must not
77+
// depend on name preceding version
78+
body := `{"server": {"version": "1.99.0", "name": "Synapse"}}`
79+
res := runSynapseModule(t, 200, body)
80+
if len(res.Findings) == 0 {
81+
t.Fatal("expected a synapse finding on reordered keys")
82+
}
83+
if v := synapseExtract(res, "synapse_version"); v != "1.99.0" {
84+
t.Errorf("synapse_version=%q on reordered keys, want 1.99.0", v)
85+
}
86+
})
87+
88+
t.Run("a 404 is not a leak", func(t *testing.T) {
89+
if res := runSynapseModule(t, 404, "not found"); len(res.Findings) > 0 {
90+
t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
91+
}
92+
})
93+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package modules_test
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
"time"
9+
10+
"github.com/vmfunc/sif/internal/modules"
11+
)
12+
13+
func runVaultwardenModule(t *testing.T, status int, body string) *modules.Result {
14+
t.Helper()
15+
def, err := modules.ParseYAMLModule("../../modules/recon/vaultwarden-version-exposure.yaml")
16+
if err != nil {
17+
t.Fatalf("parse vaultwarden module: %v", err)
18+
}
19+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
20+
w.WriteHeader(status)
21+
_, _ = w.Write([]byte(body))
22+
}))
23+
defer srv.Close()
24+
25+
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
26+
Timeout: 5 * time.Second,
27+
Threads: 2,
28+
})
29+
if err != nil {
30+
t.Fatalf("execute vaultwarden module: %v", err)
31+
}
32+
return res
33+
}
34+
35+
func vaultwardenExtract(res *modules.Result, key string) string {
36+
for _, f := range res.Findings {
37+
if v := f.Extracted[key]; v != "" {
38+
return v
39+
}
40+
}
41+
return ""
42+
}
43+
44+
func TestVaultwardenVersionExposureModule(t *testing.T) {
45+
// shape taken directly from dani-garcia/vaultwarden src/api/core/mod.rs fn config()
46+
vaultwardenBody := `{"version":"2025.12.0","gitHash":"a1b2c3d",` +
47+
`"server":{"name":"Vaultwarden","url":"https://github.com/dani-garcia/vaultwarden"},` +
48+
`"settings":{"disableUserRegistration":false},` +
49+
`"environment":{"vault":"https://vault.example.com"}}`
50+
51+
t.Run("an exposed vaultwarden config endpoint is flagged and versioned", func(t *testing.T) {
52+
res := runVaultwardenModule(t, 200, vaultwardenBody)
53+
if len(res.Findings) == 0 {
54+
t.Fatal("expected a vaultwarden finding")
55+
}
56+
if v := vaultwardenExtract(res, "vaultwarden_version"); v != "2025.12.0" {
57+
t.Errorf("vaultwarden_version=%q, want 2025.12.0", v)
58+
}
59+
})
60+
61+
t.Run("an official bitwarden server config is not flagged as vaultwarden", func(t *testing.T) {
62+
// the official bitwarden server config endpoint shares the same json
63+
// shape (clients are built against both) but reports its own server name
64+
body := `{"version":"2025.12.0","gitHash":"deadbee",` +
65+
`"server":{"name":"Bitwarden","url":"https://github.com/bitwarden/server"},` +
66+
`"settings":{"disableUserRegistration":true}}`
67+
if res := runVaultwardenModule(t, 200, body); len(res.Findings) > 0 {
68+
t.Errorf("an official bitwarden config should not match vaultwarden, got %d findings", len(res.Findings))
69+
}
70+
})
71+
72+
t.Run("a generic json with a version field is not vaultwarden", func(t *testing.T) {
73+
body := `{"version":"1.0.0","server":{"name":"SomeOtherApp"}}`
74+
if res := runVaultwardenModule(t, 200, body); len(res.Findings) > 0 {
75+
t.Errorf("a generic json should not match, got %d findings", len(res.Findings))
76+
}
77+
})
78+
79+
t.Run("a 404 is not a leak", func(t *testing.T) {
80+
if res := runVaultwardenModule(t, 404, "not found"); len(res.Findings) > 0 {
81+
t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
82+
}
83+
})
84+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Authentik Version Endpoint Exposure Detection Module
2+
3+
id: authentik-version-exposure
4+
info:
5+
name: Authentik Version Exposure
6+
author: sif
7+
severity: info
8+
description: Detects an Authentik identity provider that discloses its version family over the public default authentication flow page, rendered before any credentials are submitted
9+
tags: [authentik, sso, idp, identity, fingerprint, version, recon]
10+
11+
type: http
12+
13+
http:
14+
method: GET
15+
paths:
16+
- "{{BaseURL}}/if/flow/default-authentication-flow/"
17+
18+
matchers:
19+
- type: word
20+
part: body
21+
words:
22+
- "window.authentik"
23+
24+
- type: word
25+
part: body
26+
words:
27+
- "versionFamily"
28+
29+
- type: status
30+
status:
31+
- 200
32+
33+
extractors:
34+
- type: regex
35+
name: authentik_version_family
36+
part: body
37+
regex:
38+
- 'versionFamily:\s*"([^"]+)"'
39+
group: 1
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Synapse Matrix Homeserver Federation Version Exposure Detection Module
2+
3+
id: synapse-federation-version-exposure
4+
info:
5+
name: Synapse Matrix Homeserver Federation Version Exposure
6+
author: sif
7+
severity: info
8+
description: Detects a Synapse matrix homeserver that discloses its implementation name and version over the federation version endpoint; this route is unauthenticated by the matrix server-server spec so any homeserver can identify a remote peer before federating with it
9+
tags: [synapse, matrix, homeserver, federation, fingerprint, version, recon]
10+
11+
type: http
12+
13+
http:
14+
method: GET
15+
paths:
16+
- "{{BaseURL}}/_matrix/federation/v1/version"
17+
18+
matchers:
19+
- type: regex
20+
part: body
21+
regex:
22+
- '"name"\s*:\s*"Synapse"'
23+
24+
- type: word
25+
part: body
26+
words:
27+
- "\"version\""
28+
29+
- type: status
30+
status:
31+
- 200
32+
33+
extractors:
34+
- type: regex
35+
name: synapse_version
36+
part: body
37+
regex:
38+
- '"version"\s*:\s*"([^"]+)"'
39+
group: 1
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Vaultwarden Version Endpoint Exposure Detection Module
2+
3+
id: vaultwarden-version-exposure
4+
info:
5+
name: Vaultwarden Version Exposure
6+
author: sif
7+
severity: info
8+
description: Detects a Vaultwarden (unofficial bitwarden-compatible) server that discloses its identity and client-compat version over the pre-login config endpoint; this route is served with no auth guard by design so bitwarden clients can read it before a user logs in
9+
tags: [vaultwarden, bitwarden, password-manager, fingerprint, version, recon]
10+
11+
type: http
12+
13+
http:
14+
method: GET
15+
paths:
16+
- "{{BaseURL}}/api/config"
17+
18+
matchers:
19+
- type: regex
20+
part: body
21+
regex:
22+
- '"server"\s*:\s*\{\s*"name"\s*:\s*"Vaultwarden"'
23+
24+
- type: word
25+
part: body
26+
words:
27+
- "\"version\""
28+
29+
- type: status
30+
status:
31+
- 200
32+
33+
extractors:
34+
- type: regex
35+
name: vaultwarden_version
36+
part: body
37+
regex:
38+
- '"version"\s*:\s*"([^"]+)"'
39+
group: 1

0 commit comments

Comments
 (0)