# Risk context for a self-hosted Redash deployment.
#
# This file lives at `.k9security/risk-context.yaml` in the repository. It is
# the input YOU write: it records how critical this system is, where its code
# actually runs, who is trusted, and which risks this deployment carries
# deliberately. k9 reads it when scoring dependency alerts and reports which
# revision of it produced the verdicts.
#
# THE FOUR EXECUTION CONTEXTS COME FROM REDASH'S OWN PACKAGING, not from
# anything invented for this file. `bin/docker-entrypoint` defines:
#
#   server()     exec gunicorn -b [::]:5000 ... redash.wsgi:app
#   worker()     exec supervisord -c worker.conf   -> ./manage.py rq worker
#   scheduler()  exec /app/manage.py rq scheduler   (folded into redash-worker)
#   tests()      exec pytest
#
# plus the two the JavaScript manifests imply: the bundle DELIVERED TO THE
# BROWSER, and the tooling that builds it. Declare the places your code runs,
# not the places you imagine it might.
#
# THE SCHEDULER IS MERGED INTO THE WORKER rather than declared separately. It
# shares the worker's manifest, code base, credentials and queue, has no
# inbound listener, and only enqueues the jobs the worker runs — so it would
# produce a duplicate set of verdicts, not a new one. Declaring a context is
# worth it when that context can reach different code or different input.
#
# TWO THINGS WORTH KNOWING BEFORE YOU EDIT THIS FILE:
#
# 1. The two Python contexts share ONE `poetry.lock` and one codebase, so they
#    are NOT expected to differ on most alerts. The case that justifies
#    declaring them separately at all: a non-admin's json_ds query controls an
#    OUTBOUND HOSTNAME, and queries EXECUTE in the RQ worker, not in gunicorn.
#    So the idna family lands reachable in `redash-worker` and is argued down
#    in `redash-server` — but see the advocate/private-address bound in that
#    context, which limits the claim to hostname PARSING rather than an
#    internal-network pivot.
# 2. `redash-build` CANNOT BE SEPARATED FROM THE SHIPPING CONTEXTS BY `paths`.
#    Build tooling and shipped code live in the SAME lock files — `dompurify`
#    appears in both `viz-lib/yarn.lock` and `yarn.lock`, and so does `webpack`.
#    Contexts bind by path, so the build/runtime split is carried by
#    per-context REACHABILITY instead: `webpack` is `code_not_loaded` in the
#    browser bundle (it BUILDS the bundle, it is not IN it) and reachable in
#    the build context; `dompurify` is the mirror image. This applies to any
#    single-lockfile project — a Poetry `dev` group, npm `devDependencies` —
#    and is not specific to Redash.
#
# PATHS NAME THE LOCK FILE, ALWAYS. Every Redash alert names a lockfile and
# never a manifest — `poetry.lock` (120), `viz-lib/yarn.lock` (88), `yarn.lock`
# (64) — so a context listing only `/pyproject.toml` or `/package.json` would
# match ZERO of 272 alerts and every one would fall silently to the default
# context. This is the most common way a risk-context file goes wrong.
schema_version: "1.0"

project:
  name: redash
  summary: >
    Self-hosted Redash BI/dashboarding platform at v26.3.0, serving a mid-size
    company's internal analytics. Flask backend (web + RQ worker + scheduler)
    with a JavaScript frontend delivered to analysts' browsers.
  open_source: true

reviewed:
  date: "2026-07-30"
  by: "claude-opus-5, from redash's bin/docker-entrypoint and worker.conf"
  commit: 87af4dc

execution_contexts:
  # `default: true` so a dependency matching no context still scores. A
  # dependency you forgot to route is never silently skipped.
  - id: redash-server
    default: true
    applies_to: >
      The gunicorn WSGI service (`bin/docker-entrypoint server` →
      `redash.wsgi:app`, port 5000). Handles every authenticated request: the
      SSO login flow, query authoring and parameters, dashboard and
      visualization configuration, and the REST API.
    paths:
      include:
        - /poetry.lock
        - /pyproject.toml
        - /redash/**
    frameworks:
      - flask
      - gunicorn
      - sqlalchemy
    asset_context:
      asset_criticality: tier_2
      network_exposure: internal
      lifecycle: production
    deployment: >
      Reachable only from the corporate network, behind a reverse proxy. Access
      is authenticated via enterprise SSO (SAML or Google OAuth) with DEFAULT
      Redash configuration — JWT proxy-login (REDASH_JWT_LOGIN_ENABLED) is NOT
      enabled, and there is no anonymous access. Holds business-sensitive
      analytics (revenue metrics, operational data); no regulated PII.


      This process ACCEPTS the untrusted input but does not execute queries.
      Query text, parameters and visualization config arrive here and are
      persisted; the outbound call they describe is made by redash-worker.

  - id: redash-worker
    applies_to: >
      The RQ workers (`bin/docker-entrypoint worker` → `supervisord -c
      worker.conf` → `./manage.py rq worker`). EXECUTES queries against the
      configured data sources, and delivers alert notifications.


      INCLUDES THE RQ SCHEDULER (`bin/docker-entrypoint scheduler` →
      `manage.py rq scheduler`), which enqueues periodic work — query
      refreshes, alert checks, cleanup — on a timer. Merged rather than split
      (operator decision 2026-07-30): it shares this context's manifest, code
      base, credentials and queue, and has no inbound listener either. The
      scheduler does not itself execute queries; it schedules the jobs this
      context runs.
    paths:
      include:
        - /poetry.lock
        - /pyproject.toml
        - /redash/**
    frameworks:
      - rq
      - rq-scheduler
      - sqlalchemy
    asset_context:
      asset_criticality: tier_2
      # ISOLATED, not internal: no inbound listener. It consumes jobs its own
      # web tier enqueues through Redis. Same trust zone, same data and the same
      # database credentials as the server, so criticality is unchanged.
      network_exposure: isolated
      lifecycle: production
    deployment: >
      No listening socket; consumes from Redis. Runs with the same code and
      credentials as the web tier, so blast radius is comparable — the
      difference is network position and, decisively, WHAT IT EXECUTES.


      THIS IS WHERE THE OUTBOUND HOSTNAME IS ATTACKER-CONTROLLED. The
      default-enabled HTTP-family query runners (json_ds / url) are available
      and queryable by non-admin groups — the representative BI deployment — and
      a non-admin's query may specify an absolute `url` that urljoin honors,
      overriding the admin-configured base_url. So an authenticated non-admin
      controls the outbound hostname of an httpx/requests call made by THIS
      process (the SSRF / idna surface). That is attacker-controlled, NOT
      admin-gated.


      BOUNDED BY A SHIPPED DEFAULT, and the boundary is load-bearing:
      `ENFORCE_PRIVATE_ADDRESS_BLOCK` defaults TRUE
      (settings/__init__.py:76, from REDASH_ENFORCE_PRIVATE_IP_BLOCK), and
      utils/requests_session.py:13 uses it to import **advocate** in place of
      requests for the query-runner session. So the classic internal-network
      SSRF pivot is blocked by default. What remains attacker-controlled is the
      HOSTNAME ITSELF, which is still parsed and encoded on the way out — the
      idna surface — and advocate subclasses requests.Session, so requests,
      urllib3 and idna are all still on the path.


      Read the two together: a finding that needs to reach an internal address
      is argued DOWN here; a finding in hostname parsing/encoding is argued UP.
      Stated as a per-context fact because it is the one thing that separates
      this context from redash-server, and it decides verdicts: the same
      dependency is argued down there and up here.

  - id: redash-browser-bundle
    applies_to: >
      The JavaScript DELIVERED TO AND EXECUTED IN THE ANALYST'S BROWSER — the
      client application and the viz-lib visualization library it bundles.
    paths:
      include:
        - /yarn.lock
        - /package.json
        - /viz-lib/yarn.lock
        - /viz-lib/package.json
        - /client/**
        - /viz-lib/src/**
    frameworks:
      - react
      - antd
    asset_context:
      asset_criticality: tier_2
      # The code runs on employee workstations reached through the internal web
      # tier; it is not itself a listening service.
      network_exposure: internal
      lifecycle: production
    deployment: >
      Executes in the browser of every authenticated user who opens a dashboard
      or query. The rendered content is authored by OTHER authenticated users —
      query results, dashboard text widgets, visualization configuration — so
      the untrusted input is stored and cross-user, not request-borne.


      THE CUSTOM-JS ACCEPTANCE APPLIES HERE AND ONLY HERE. See accepted_risks:
      it bounds the ATTACKER MODEL for this context, because the capability it
      concedes (an authenticated user running script in a viewer's page) is a
      capability of this execution environment. It is not a statement about the
      server, the worker, or the scheduler.

  - id: redash-build
    applies_to: >
      Build, dev and test tooling: bundlers, transpilers, linters, dev servers,
      the Cypress suite, and `bin/docker-entrypoint tests` (pytest). Runs in CI
      and on developer machines; NEVER in the deployed service or the shipped
      bundle.
    paths:
      # Deliberately the SAME manifests as the shipping contexts. See the header:
      # tooling and shipped code share these lock files, so `paths` cannot
      # separate them and per-context reachability carries the split instead.
      include:
        - /poetry.lock
        - /pyproject.toml
        - /yarn.lock
        - /package.json
        - /viz-lib/yarn.lock
        - /viz-lib/package.json
        - /webpack.config.js
        - /cypress/**
        - /tests/**
    frameworks:
      - webpack
      - babel
      - cypress
      - pytest
    asset_context:
      asset_criticality: tier_2
      # CI runners have egress, so `internal` rather than `isolated`. A
      # dependency that runs only in build tooling carries the BUILD
      # environment's context, not the deployed service's.
      network_exposure: internal
      lifecycle: dev
    deployment: >
      Executes against first-party configuration in CI and on developer
      machines. Inputs are the project's own source, config and test fixtures;
      there is no end-user input on this path.


      Because this context matches the same lock files as the shipping ones,
      the question that separates them is always "is this dependency's
      vulnerable code loaded HERE?" — build tooling is loaded here and not in
      the bundle; shipped libraries are the reverse.

trust_model:
  untrusted_actors:
    - id: authenticated-non-admin-user
      description: >
        Any authenticated employee with a Redash account. The PRIMARY untrusted
        actor: by design they author SQL queries, query parameters, dashboards
        and visualization config, so all of that is attacker-controllable input.
      controls:
        - Query text and query parameters.
        # The specific capability that separates redash-worker from
        # redash-server. Kept explicit because it decides verdicts.
        - The OUTBOUND HOSTNAME of a json_ds/url query runner call (an absolute
          `url` overrides the admin-configured base_url) — executed in
          redash-worker.
        - Dashboard text widgets and visualization configuration, rendered in
          other users' browsers (redash-browser-bundle).
    # Not a person, and easy to miss: rows a connected warehouse returns are
    # attacker-influenced whenever that warehouse holds user-submitted content,
    # and they flow through the worker into the browser bundle.
    - id: data-source-content
      description: >-
        The rows returned by connected data sources.
      controls:
        - Result values rendered into dashboards and visualizations.
  trusted_actors:
    - id: redash-administrator
      description: >
        Staff with admin rights over data-source connection config (hosts,
        credentials) and server settings.
      rationale: >
        Those capabilities are equivalent to configuring the service by design,
        so a vulnerability reachable only by an admin acting within their
        granted capability is not attacker-controlled.
    - id: corporate-idp
      description: The enterprise SAML / Google OAuth provider.
      rationale: >
        Assertions and id_tokens are retrieved over the back channel, so their
        contents are trusted input. JWT proxy-login is NOT enabled.
  out_of_scope:
    - id: unauthenticated-network-party
      rationale: >
        The edge is SSO-gated and the service is not internet-reachable.


        SCOPE NOTE: Redash DOES ship an unauthenticated surface — a
        per-dashboard API-key token grants access with NO login
        (handlers/dashboards.py:185-294), and those endpoints relax framing via
        csp_allows_embeding. This deployment does not use public dashboard
        sharing. That is a DEPLOYMENT fact the repository cannot establish
        either way, stated explicitly because "unauthenticated parties are out
        of scope" would otherwise read as a claim about the product rather than
        about this installation.

accepted_risks:
  # SCOPE NOTE: this acceptance belongs to the `redash-browser-bundle`
  # context — that is where the conceded capability exists. An acceptance
  # cannot yet be BOUND to an execution context, so the boundary is carried by
  # the `scope` prose below. It must NOT be applied to redash-server,
  # redash-worker or redash-build; a finding in those contexts argued down on
  # "an authenticated user can already run script in a viewer's page" is this
  # acceptance over-reaching.
  - id: custom-js-visualizations
    cwe: CWE-79
    capability: >-
      authenticated users execute arbitrary JavaScript in other users' browsers
      via saved custom visualization code
    actor: authenticated non-admin user
    scope: >-
      The browser-delivered custom visualization rendering path only (the chart
      Renderer's custom-code path, in the redash-browser-bundle execution
      context). Does NOT extend to other XSS paths, sanitizer bypasses, or HTML
      rendering elsewhere in the product, and does NOT reach any server-side
      execution context.
    evidence: REDASH_FEATURE_ALLOW_CUSTOM_JS_VISUALIZATIONS=true
    rationale: >-
      Sharing custom visualization code across the company is a core product
      capability and is enabled by default; this deployment carries the risk
      deliberately.
    # attacker_model is the strongest setting: it may change a REACHABILITY
    # verdict, not merely an impact. Use it only when you mean it.
    #
    # The test the copy states, restated here because it is the boundary: if
    # this feature flag were false, would the finding still be exploitable? If
    # yes, the acceptance does not apply and the finding stands on its merits.
    bounds: attacker_model

  # impact_only, NOT attacker_model: the
  # capability is real but it does not make anything unreachable — turning the
  # query runners off would make SSRF-shaped findings matter less, not vanish
  # from the code path. That is the copy's own test for the two values.
  - id: server-side-url-fetching
    cwe: CWE-918
    capability: >-
      authenticated non-admin users cause outbound HTTP requests from the
      server to PUBLIC addresses of their choosing, via the url and json_ds
      query runners
    actor: authenticated non-admin user
    scope: >-
      The default-enabled url / json_ds query runners only. Does NOT cover
      requests to private or link-local address ranges — those are blocked by
      the shipped default below — and does NOT cover any other outbound path
      (webhooks, alert destinations, data-source drivers).
    evidence: >-
      ENFORCE_PRIVATE_ADDRESS_BLOCK=true (settings/__init__.py:76), which
      routes these fetches through advocate rather than requests
      (utils/requests_session.py:13); default_query_runners includes url and
      json_ds (settings/__init__.py:286)
    rationale: >-
      Pulling external API metrics into dashboards is why this deployment runs
      a JSON/REST data source at all; the private-address block is left on.
    bounds: impact_only

  # This is what Redash IS, so the acceptance is nearly tautological — the
  # BOUNDARY is the part that carries weight.
  - id: user-authored-sql
    cwe: CWE-89
    capability: >-
      authenticated users author and execute arbitrary SQL against connected
      data sources, with the privileges of the configured connection
    actor: authenticated non-admin user
    scope: >-
      Query execution against configured data sources only. Does NOT cover
      injection into Redash's OWN metadata database, and does NOT cover any
      path that lets a user reach a data source they were not granted.
    evidence: >-
      the query-authoring UI and the query_runner package are the product's
      primary function
    rationale: >-
      Self-service querying is the entire purpose of deploying Redash; the
      boundary that matters is the connection's own privileges.
    bounds: impact_only
