Skip to content

Commit 855a47c

Browse files
authored
feat(modules): add sidekiq, flower and rq-dashboard exposure modules (#254)
add recon modules for self-hosted background-job dashboards that ship no authentication of their own and rely on the hosting application to protect them: sidekiq web /sidekiq/stats discloses the redis server internals and the job queue, celery flower /api/workers (reachable only when its api is deliberately opened) discloses every worker's broker config and registered tasks, and rq-dashboard /0/data/queues.json discloses the redis-backed queue names and job counts; each open instance also allows killing, retrying or deleting jobs, while a deployment protected by the application returns a redirect or 401 and is not flagged.
1 parent b4dec11 commit 855a47c

4 files changed

Lines changed: 237 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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 runJobDashModule(t *testing.T, file string, status int, body string) *modules.Result {
14+
t.Helper()
15+
def, err := modules.ParseYAMLModule(file)
16+
if err != nil {
17+
t.Fatalf("parse %s: %v", file, err)
18+
}
19+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *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 %s: %v", file, err)
31+
}
32+
return res
33+
}
34+
35+
func jobDashExtract(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 TestJobDashboardExposureModules(t *testing.T) {
45+
const sidekiq = "../../modules/recon/sidekiq-web-exposure.yaml"
46+
const flower = "../../modules/recon/celery-flower-exposure.yaml"
47+
const rq = "../../modules/recon/rq-dashboard-exposure.yaml"
48+
49+
t.Run("a sidekiq stats dump is flagged with its redis version", func(t *testing.T) {
50+
body := `{"sidekiq":{"processed":12345,"failed":67,"busy":3,"processes":2,"enqueued":10,` +
51+
`"scheduled":5,"retries":1,"dead":0,"default_latency":0},"redis":{"redis_version":"7.2.4",` +
52+
`"uptime_in_days":"12","connected_clients":"8","used_memory_human":"2.50M",` +
53+
`"used_memory_peak_human":"3.10M"},"server_utc_time":"18:00:00 UTC"}`
54+
res := runJobDashModule(t, sidekiq, 200, body)
55+
if len(res.Findings) == 0 {
56+
t.Fatal("expected a sidekiq finding")
57+
}
58+
if v := jobDashExtract(res, "redis_version"); v != "7.2.4" {
59+
t.Errorf("redis_version=%q, want 7.2.4", v)
60+
}
61+
})
62+
63+
t.Run("a bare redis-info body without default_latency is not flagged as sidekiq", func(t *testing.T) {
64+
if res := runJobDashModule(t, sidekiq, 200, `{"redis_version":"7.2.4","server_utc_time":"x"}`); len(res.Findings) > 0 {
65+
t.Errorf("a redis info blob should not match sidekiq, got %d findings", len(res.Findings))
66+
}
67+
})
68+
69+
t.Run("a flower workers api is flagged with the celery version", func(t *testing.T) {
70+
body := `{"celery@worker1":{"active_queues":[{"name":"celery","exchange":{"name":"celery",` +
71+
`"type":"direct"},"routing_key":"celery"}],"conf":{"broker_url":"redis://localhost:6379/0",` +
72+
`"result_backend":"redis://localhost:6379/0"},"registered":["tasks.add","tasks.send_email"],` +
73+
`"stats":{"sw_ident":"py-celery","sw_ver":"5.3.6","sw_sys":"Linux","pool":{"max-concurrency":4},` +
74+
`"broker":{"hostname":"localhost","transport":"redis"}},"timestamp":1719345600.0}}`
75+
res := runJobDashModule(t, flower, 200, body)
76+
if len(res.Findings) == 0 {
77+
t.Fatal("expected a flower finding")
78+
}
79+
if v := jobDashExtract(res, "celery_version"); v != "5.3.6" {
80+
t.Errorf("celery_version=%q, want 5.3.6", v)
81+
}
82+
})
83+
84+
t.Run("a worker blob without conf is not flagged as flower", func(t *testing.T) {
85+
if res := runJobDashModule(t, flower, 200, `{"celery@w":{"active_queues":[],"registered":["tasks.add"]}}`); len(res.Findings) > 0 {
86+
t.Errorf("a confless worker blob should not match flower, got %d findings", len(res.Findings))
87+
}
88+
})
89+
90+
t.Run("an rq queues dump is flagged with the first queue name", func(t *testing.T) {
91+
body := `{"queues":[{"name":"default","count":42,"queued_url":"/0/view/jobs/default/queued/...",` +
92+
`"failed_job_registry_count":3,"failed_url":"...","started_job_registry_count":1,"started_url":"...",` +
93+
`"deferred_job_registry_count":0,"deferred_url":"...","finished_job_registry_count":100,` +
94+
`"finished_url":"...","canceled_job_registry_count":0,"canceled_url":"...",` +
95+
`"scheduled_job_registry_count":5,"scheduled_url":"..."}]}`
96+
res := runJobDashModule(t, rq, 200, body)
97+
if len(res.Findings) == 0 {
98+
t.Fatal("expected an rq finding")
99+
}
100+
if v := jobDashExtract(res, "rq_queue_name"); v != "default" {
101+
t.Errorf("rq_queue_name=%q, want default", v)
102+
}
103+
})
104+
105+
t.Run("a queues blob without the registry counts is not flagged as rq", func(t *testing.T) {
106+
if res := runJobDashModule(t, rq, 200, `{"queues":[{"name":"q","failed_job_registry_count":0}]}`); len(res.Findings) > 0 {
107+
t.Errorf("a partial queues blob should not match rq, got %d findings", len(res.Findings))
108+
}
109+
})
110+
111+
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
112+
for _, file := range []string{sidekiq, flower, rq} {
113+
if res := runJobDashModule(t, file, 200, "ok"); len(res.Findings) > 0 {
114+
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
115+
}
116+
}
117+
})
118+
119+
t.Run("a 404 is not a leak", func(t *testing.T) {
120+
for _, file := range []string{sidekiq, flower, rq} {
121+
if res := runJobDashModule(t, file, 404, "not found"); len(res.Findings) > 0 {
122+
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
123+
}
124+
}
125+
})
126+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Celery Flower Worker API Exposure Detection Module
2+
3+
id: celery-flower-exposure
4+
info:
5+
name: Celery Flower Worker API Exposure
6+
author: sif
7+
severity: high
8+
description: Detects a Celery Flower instance with its api opened that leaks worker broker config and registered tasks and can execute tasks
9+
tags: [flower, celery, broker, python, background-jobs, dashboard, exposure, unauth, recon]
10+
11+
type: http
12+
13+
http:
14+
method: GET
15+
paths:
16+
- "{{BaseURL}}/api/workers"
17+
18+
matchers:
19+
- type: word
20+
part: body
21+
words:
22+
- "\"active_queues\""
23+
- "\"registered\""
24+
- "\"conf\""
25+
condition: and
26+
27+
- type: status
28+
status:
29+
- 200
30+
31+
extractors:
32+
- type: regex
33+
name: celery_version
34+
part: body
35+
regex:
36+
- '"sw_ver"\s*:\s*"([^"]+)"'
37+
group: 1
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# RQ Dashboard Exposure Detection Module
2+
3+
id: rq-dashboard-exposure
4+
info:
5+
name: RQ Dashboard Exposure
6+
author: sif
7+
severity: medium
8+
description: Detects an unprotected RQ Dashboard that leaks Redis-backed queue names and job counts and allows requeuing and deleting jobs
9+
tags: [rq, rq-dashboard, redis, python, background-jobs, dashboard, exposure, unauth, recon]
10+
11+
type: http
12+
13+
http:
14+
method: GET
15+
paths:
16+
- "{{BaseURL}}/0/data/queues.json"
17+
18+
matchers:
19+
- type: word
20+
part: body
21+
words:
22+
- "\"queues\""
23+
- "\"failed_job_registry_count\""
24+
- "\"scheduled_job_registry_count\""
25+
condition: and
26+
27+
- type: status
28+
status:
29+
- 200
30+
31+
extractors:
32+
- type: regex
33+
name: rq_queue_name
34+
part: body
35+
regex:
36+
- '"name"\s*:\s*"([^"]+)"'
37+
group: 1
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Sidekiq Web Dashboard Exposure Detection Module
2+
3+
id: sidekiq-web-exposure
4+
info:
5+
name: Sidekiq Web Dashboard Exposure
6+
author: sif
7+
severity: high
8+
description: Detects an unauthenticated Sidekiq Web dashboard that leaks Redis internals and the job queue and allows killing and retrying jobs
9+
tags: [sidekiq, redis, background-jobs, ruby, rails, dashboard, exposure, unauth, recon]
10+
11+
type: http
12+
13+
http:
14+
method: GET
15+
paths:
16+
- "{{BaseURL}}/sidekiq/stats"
17+
18+
matchers:
19+
- type: word
20+
part: body
21+
words:
22+
- "\"default_latency\""
23+
- "\"redis_version\""
24+
- "\"server_utc_time\""
25+
condition: and
26+
27+
- type: status
28+
status:
29+
- 200
30+
31+
extractors:
32+
- type: regex
33+
name: redis_version
34+
part: body
35+
regex:
36+
- '"redis_version"\s*:\s*"([^"]+)"'
37+
group: 1

0 commit comments

Comments
 (0)