-
Notifications
You must be signed in to change notification settings - Fork 0
Security Vulnerability Analysis: dashboard_load_id Memoization Cross‐User Data Leakage
The dashboard_load_id query parameter is intended to correlate related requests during a single dashboard load for caching optimization. However, because it is client-supplied and not bound to user identity or permissions, it can be exploited as a cross-user cache key. Multiple users sharing the same dashboard_load_id will inadvertently share cached dashboard structures, card metadata, and query details through memoized functions that lack authorization context in their cache keys.
File: src/metabase/dashboards_rest/api.clj
Function: get-dashboard-fn (lines 337-342)
(def ^:private get-dashboard-fn
(memoize/ttl (fn [dashboard-load-id]
(if dashboard-load-id
(memoize/memo get-dashboard*) ; If dashboard-load-id is set, return a memoized get-dashboard*.
get-dashboard*)) ; If unset, just call through to get-dashboard*.
:ttl/threshold dashboard-load-cache-ttl))Vulnerability: The memoization cache key uses only dashboard-load-id, which is completely client-supplied and contains no user identity or permission context. When a second user requests the same dashboard with the same dashboard-load-id, they receive the cached result from the first user's request, potentially exposing:
- Dashboard structure (cards, parameters)
- Card metadata used for query building
- Raw query details
The comment above this function even acknowledges (line 288) that "different users see different things" and caching on dashboard ID alone is unsafe, yet the implementation allows different users to collide if they share the same dashboard-load-id.
File: src/metabase/dashboards_rest/api.clj
(def ^:private dashboard-load-cache-ttl
"Using 10 seconds for the cache TTL."
(* 10 1000))Vulnerability: The default 10-second TTL provides a sufficient window for an attacker to exploit the vulnerability. A pentester can:
- Generate a
dashboard_load_idas an authenticated user with elevated permissions - Request dashboard details with that ID
- Share or reuse that
dashboard_load_idas a low-permission user - Within 10 seconds, retrieve cached dashboard details they shouldn't have access to
The 10-second window is long enough to execute an attack in real-world scenarios, especially when dashboards are populated with sensitive data.
File: src/metabase/dashboards_rest/api.clj
(defn- get-dashboard
"Get Dashboard with ID.
Memoized per `*dashboard-load-id*` with a TTL of 10 seconds."
[id]
((get-dashboard-fn *dashboard-load-id*) id))Vulnerability: This function calls get-dashboard-fn with the dynamic variable *dashboard-load-id*, which is user-supplied from the HTTP request. It then uses the returned memoized function to fetch dashboard data. The cache lookup flow is:
- Look up cache entry for
dashboard-load-id(any user can use any ID) - If hit, return the memoized
get-dashboard*function - That memoized function has no user/permission context in its cache key
- Multiple users get the same cached dashboard object
File: src/metabase/dashboards_rest/api.clj
(defn- do-with-dashboard-load-id [dashboard-load-id body-fn]
(if dashboard-load-id
(binding [*dashboard-load-id* dashboard-load-id]
(lib-be/with-existing-metadata-provider-cache (dashboard-load-metadata-provider-cache dashboard-load-id)
(log/debugf "Using dashboard_load_id %s" dashboard-load-id)
(body-fn)))
(do
(log/debug "No dashboard_load_id provided")
(body-fn))))
(defmacro ^:private with-dashboard-load-id [dashboard-load-id & body]
`(do-with-dashboard-load-id ~dashboard-load-id (^:once fn* [] ~@body)))Vulnerability: The binding mechanism treats dashboard-load-id equally for all users without any validation or scoping. The dynamic variable *dashboard-load-id* is bound from the HTTP parameter and used directly as a cache key, creating the cross-user collision vector.
File: src/metabase/dashboards_rest/api.clj
(api.macros/defendpoint :get "/:id"
"Get Dashboard with ID."
[{:keys [id]} :- [:map
[:id [:or ms/PositiveInt ms/NanoIdString]]]
{dashboard-load-id :dashboard_load_id}]
(with-dashboard-load-id dashboard-load-id
(let [resolved-id (eid-translation/->id-or-404 :dashboard id)
dashboard (get-dashboard resolved-id)]
(u/prog1 (first (revisions/with-last-edit-info [dashboard] :dashboard))
(events/publish-event! :event/dashboard-read {:object-id (:id dashboard) :user-id api/*current-user-id*})))))Vulnerability: This endpoint extracts dashboard-load-id from the query parameter and uses it as a cache key. The same dashboard ID can be requested by different users with the same dashboard-load-id, causing cache collision.
File: src/metabase/dashboards_rest/api.clj
(api.macros/defendpoint :get "/:id/query_metadata"
"Get all of the required query metadata for the cards on dashboard."
[{:keys [id]} :- [:map
[:id [:or ms/PositiveInt ms/NanoIdString]]]
{dashboard-load-id :dashboard_load_id}]
(with-dashboard-load-id dashboard-load-id
(perms/with-relevant-permissions-for-user api/*current-user-id*
(let [resolved-id (eid-translation/->id-or-404 :dashboard id)
dashboard (get-dashboard resolved-id)]
(queries/batch-fetch-dashboard-metadata [dashboard])))))Vulnerability: This endpoint provides detailed metadata about cards and their queries. When cached via shared dashboard-load-id, different users with different permission levels can access metadata from the same cache entry, bypassing their intended permission restrictions.
File: src/metabase/dashboards_rest/api.clj
(api.macros/defendpoint :post "/:dashboard-id/dashcard/:dashcard-id/card/:card-id/query"
"Run the query associated with a Saved Question (`Card`) in the context of a `Dashboard` that includes it."
[{:keys [dashboard-id dashcard-id card-id]} :- [:map
[:dashboard-id ms/PositiveInt]
[:dashcard-id ms/PositiveInt]
[:card-id ms/PositiveInt]]
_query-params
{:keys [dashboard_load_id], :as body} :- [:map
[:dashboard_load_id {:optional true} [:maybe ms/NonBlankString]]
[:parameters {:optional true} [:maybe [:sequential ParameterWithID]]]]]
(with-dashboard-load-id dashboard_load_id
(m/mapply qp.dashboard/process-query-for-dashcard
(merge
body
{:dashboard-id dashboard-id
:card-id card-id
:dashcard-id dashcard-id}))))Vulnerability: While this endpoint is stricter with permission checks (as noted in the issue report), it still accepts dashboard_load_id and could be indirectly affected if the underlying metadata is compromised through the other endpoints.
A user with low or no database access can exploit this vulnerability:
Attack Scenario:
1. User A (admin, has full permissions) loads a dashboard and receives dashboard_load_id=X
→ Dashboard structure and metadata cached with key X
→ Cache contains sensitive queries and field metadata
2. User A shares or publishes the dashboard_load_id=X somehow, or attacker sniffs it
3. User B (restricted user, no dashboard access) loads the same dashboard within 10 seconds
→ Makes identical request with dashboard_load_id=X
→ Query hits the memoized cache from User A's request
→ User B receives User A's cached dashboard structure and Query details
→ User B sees:
- Dashboard cards and layout
- Card metadata (field names, types, descriptions)
- Raw query definitions
- Sensitive table/schema names
- Database structure information
4. Time window is 10 seconds from User A's initial request
Data Exposure Confirmed by Pentester:
- Table fingerprints (data profiles)
- Detailed metadata (field names, purposes, preservation details)
- Raw query definitions (on KPI dashboards)
- Schema and table structure information
The vulnerability stems from a fundamental design flaw in the memoization strategy:
-
Authorization Context Not Included in Cache Key - The cache key for
get-dashboard*relies entirely ondashboard-load-id, which is client-supplied and untrusted. It does not include:- Current user ID
- User's permission level
- User's role
- User's group memberships
- Permission revisions or hashes
-
Client-Supplied Cache Identifier - Unlike server-issued tokens that can be bound to sessions,
dashboard-load-idis completely controlled by the client. An attacker can:- Generate any UUID
- Request with elevated permissions (as admin)
- Reuse the same ID with reduced permissions
- Collide with other users' requests
-
Insufficient TTL Defense - While a 10-second TTL provides some mitigation, it's not a security boundary. It only adds a small time window for exploitation, not prevention.
-
Acknowledged Risk Ignored - The code comment at line 288 explicitly states that "different users see different things" and caching on dashboard ID alone is unsafe, yet the implementation was deployed with this vulnerability intact.
-
Metadata Provider Cache Inheritance - The
dashboard-load-metadata-provider-cache(line 345) also uses onlydashboard-load-idwithout user context:(def ^:private dashboard-load-metadata-provider-cache (memoize/ttl (fn [_dashboard-load-id] (atom (cache/basic-cache-factory {}))) :ttl/threshold dashboard-load-cache-ttl))
This compounds the issue by sharing metadata caches across users.
Severity: HIGH
Affected Data:
- Dashboard structure and composition
- Card metadata and relationships
- Query definitions and SQL statements
- Database schema and table information
- Field metadata and data types
- Sensitive business logic embedded in queries
Affected Users:
- Any user can access data from dashboards they don't have permission to view
- Restricted users can access query details meant for admins/data analysts
- Users with no database access can enumerate schemas and tables
Replicability:
- Requires coordination of requests within a 10-second window
- Requires knowledge of dashboard load pattern (but UUIDs can be predicted or brute-forced)
- Fully automated and scalable attack
| Location | Component | Type | Required Fix |
|---|---|---|---|
src/metabase/dashboards_rest/api.clj:337-342 |
get-dashboard-fn |
Critical | Include user identity in memoization key |
src/metabase/dashboards_rest/api.clj:345-347 |
dashboard-load-metadata-provider-cache |
Critical | Include user identity in cache key |
src/metabase/dashboards_rest/api.clj:289-292 |
dashboard-load-cache-ttl |
Mitigation | Reduce TTL or set to 0 |
src/metabase/dashboards_rest/api.clj:363-368 |
get-dashboard |
Critical | Modify cache key composition |
src/metabase/dashboards_rest/api.clj:614-627 |
GET /:id endpoint |
Required | Validate user context before cache access |
src/metabase/dashboards_rest/api.clj:1133-1144 |
GET /:id/query_metadata endpoint |
Required | Validate user context before cache access |
Security Level: Excellent | Performance Impact: Minimal
Modify the memoization keys to include user identity:
(def ^:private get-dashboard-fn
(memoize/ttl (fn [[dashboard-load-id user-id]]
(if dashboard-load-id
(memoize/memo get-dashboard*)
get-dashboard*))
:ttl/threshold dashboard-load-cache-ttl))
(defn- get-dashboard [id]
((get-dashboard-fn [*dashboard-load-id* api/*current-user-id*]) id))Advantages:
- Maintains performance benefits of caching
- Each user gets their own cache entry
- No permission check overhead
- Simple to implement
- Preserves existing API and behavior
Considerations:
- Multiple users per dashboard still create separate cache entries
- Cache size grows with number of unique user combinations
Security Level: Excellent | Performance Impact: Moderate
Replace dashboard_load_id with server-issued, session-bound tokens:
;; Generate on first request
(defn- issue-dashboard-session-token []
{:token (generate-random-token)
:issued-at (System/currentTimeMillis)
:user-id api/*current-user-id*
:session-id (current-session-id)})
;; Validate token
(defn- validate-dashboard-session-token [token]
(let [entry (get-token-store token)]
(and entry
(= (:user-id entry) api/*current-user-id*)
(< (- (System/currentTimeMillis) (:issued-at entry)) dashboard-session-token-ttl))))Advantages:
- Server has full control over token lifecycle
- Tokens automatically scoped to user and session
- Tokens are short-lived and non-reusable
- Can revoke tokens immediately if needed
- Eliminates UUID collision vector
Disadvantages:
- Requires breaking API changes
- Requires token storage/session management
- Frontend needs to handle token generation workflow
Security Level: Excellent | Performance Impact: High
Set dashboard-load-cache-ttl to 0 to disable caching entirely:
(def ^:private dashboard-load-cache-ttl 0)Advantages:
- Immediately eliminates the vulnerability
- No code changes required
- Fully reversible
Disadvantages:
- Significant performance degradation
- Negates the caching optimization benefits
- Only acceptable as emergency/temporary fix
Security Level: Good | Performance Impact: Low**
Add explicit permission checks that bypass cache on permission changes:
(defn- get-dashboard [id]
(let [cached-fn (get-dashboard-fn *dashboard-load-id*)
dashboard (cached-fn id)]
;; Always verify current user has read access, even if cached
(api/read-check :model/Dashboard id)
dashboard))Advantages:
- Works with existing code
- Minimal performance impact
- Defense in depth
Disadvantages:
- Requires permission check on every request
- Doesn't prevent metadata caching vulnerability
- Still allows data exposure within permission check delay
Implement Option 1 (Include User Identity in Cache Key) as the primary fix because:
- It completely eliminates the cross-user collision vulnerability
- Performance impact is minimal (one additional key component)
- Implementation is simple and focused
- No breaking API changes required
- Preserves existing cache benefits
Implement Option 2 (Server-Issued Tokens) as a longer-term enhancement to:
- Remove client control over cache keys entirely
- Properly scope token lifecycle to sessions
- Prevent future similar vulnerabilities
Never rely solely on Option 3 or Option 4 as they:
- Either sacrifice performance significantly
- Or only provide defense in depth without structural fix