Skip to content

Security Vulnerability Analysis: dashboard_load_id Memoization Cross‐User Data Leakage

Otávio Calaça edited this page Feb 18, 2026 · 1 revision

Issue Summary

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.

Vulnerable Code Locations

1. Core Vulnerability: get-dashboard-fn Memoization

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.

2. Cache TTL Definition

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:

  1. Generate a dashboard_load_id as an authenticated user with elevated permissions
  2. Request dashboard details with that ID
  3. Share or reuse that dashboard_load_id as a low-permission user
  4. 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.

3. Memoization Entry Point: get-dashboard

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:

  1. Look up cache entry for dashboard-load-id (any user can use any ID)
  2. If hit, return the memoized get-dashboard* function
  3. That memoized function has no user/permission context in its cache key
  4. Multiple users get the same cached dashboard object

4. Dashboard Binding Context

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.

5. GET /dashboard/:id Endpoint

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.

6. GET /dashboard/:id/query_metadata Endpoint

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.

7. POST /dashboard/:id/dashcard/:dashcard-id/card/:card-id/query Endpoint

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.

Privilege Escalation Attack Vector

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

Root Cause Analysis

The vulnerability stems from a fundamental design flaw in the memoization strategy:

  1. Authorization Context Not Included in Cache Key - The cache key for get-dashboard* relies entirely on dashboard-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
  2. Client-Supplied Cache Identifier - Unlike server-issued tokens that can be bound to sessions, dashboard-load-id is 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
  3. 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.

  4. 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.

  5. Metadata Provider Cache Inheritance - The dashboard-load-metadata-provider-cache (line 345) also uses only dashboard-load-id without 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.

Impact Assessment

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

Fix Locations Summary

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

Recommended Fix Strategy

Option 1: Include User Identity in Cache Key (Recommended)

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

Option 2: Replace Client-Supplied ID with Server-Issued Token (Comprehensive)

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

Option 3: Immediate Mitigation - Disable Caching (Emergency Only)

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

Option 4: Require Permission Check per Request (Defense in Depth)

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

Recommendation

Implement Option 1 (Include User Identity in Cache Key) as the primary fix because:

  1. It completely eliminates the cross-user collision vulnerability
  2. Performance impact is minimal (one additional key component)
  3. Implementation is simple and focused
  4. No breaking API changes required
  5. Preserves existing cache benefits

Implement Option 2 (Server-Issued Tokens) as a longer-term enhancement to:

  1. Remove client control over cache keys entirely
  2. Properly scope token lifecycle to sessions
  3. Prevent future similar vulnerabilities

Never rely solely on Option 3 or Option 4 as they:

  1. Either sacrifice performance significantly
  2. Or only provide defense in depth without structural fix