Compare commits

...

9 Commits

Author SHA1 Message Date
Claude Code
6fd6557d79 docs(sip): session/token lifecycle and extension supply-chain hardening
Proposal collecting the security-review items that are not safely shippable as
isolated PRs: session regeneration on login (A1), session termination on
account disable/delete via an invalidation epoch (A2), guest-token revocation
(A3), and extension supply-chain advisory checks (Part B). Each section gives a
proposed design, compatibility notes, and rejected alternatives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:51:33 -07:00
Evan Rusackas
8dcc7e7eec ci: stable required-check anchors for skippable matrix test jobs (#40772)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 09:50:06 -07:00
Evan Rusackas
ff5e43c8a0 ci: add timeout-minutes to compute-heavy workflow jobs (#40743)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 09:47:55 -07:00
Evan Rusackas
bdb081329f feat(websocket): validate WebSocket upgrade Origin against an allowlist (#40625)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 09:43:16 -07:00
Evan Rusackas
aa547da960 fix: remove registration_hash in the registrations API (#40643)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 09:43:03 -07:00
Evan Rusackas
966c243db6 ci: drop removed Cypress shards from required status checks (#40770)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 18:23:47 +02:00
Evan Rusackas
696705794b ci: gate docker image builds at the job level (#40723)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-03 15:39:01 -07:00
Shaitan
41572dbf9d fix(chart): restrict owner lookup to users with write access (#39304)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:00:31 +01:00
Evan Rusackas
5ba60d51fd ci: gate CodeQL analysis at the job level for docs-only PRs (#40724) 2026-06-03 23:49:59 +02:00
23 changed files with 507 additions and 53 deletions

View File

@@ -79,21 +79,17 @@ github:
- lint-check
- cypress-matrix (0, chrome)
- cypress-matrix (1, chrome)
- cypress-matrix (2, chrome)
- cypress-matrix (3, chrome)
- cypress-matrix (4, chrome)
- cypress-matrix (5, chrome)
- dependency-review
- frontend-build
- playwright-tests (chromium)
- pre-commit (current)
- pre-commit (previous)
- test-mysql
- test-postgres (current)
- test-postgres-required
- test-postgres-hive
- test-postgres-presto
- test-sqlite
- unit-tests (current)
- unit-tests-required
required_pull_request_reviews:
dismiss_stale_reviews: false

View File

@@ -15,9 +15,35 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
outputs:
python: ${{ steps.check.outputs.python }}
frontend: ${{ steps.check.outputs.frontend }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
analyze:
name: Analyze
needs: changes
# Skip on PRs that touch neither code group (e.g. docs-only) so the
# analysis runners don't spin up. push/schedule runs always proceed:
# the change-detector returns "all changed" for non-PR events.
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
actions: read
contents: read
@@ -31,16 +57,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
@@ -54,7 +74,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
with:
category: "/language:${{matrix.language}}"

View File

@@ -19,8 +19,30 @@ concurrency:
jobs:
changes:
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
outputs:
python: ${{ steps.check.outputs.python }}
frontend: ${{ steps.check.outputs.frontend }}
docker: ${{ steps.check.outputs.docker }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
setup_matrix:
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
matrix_config: ${{ steps.set_matrix.outputs.matrix_config }}
steps:
@@ -32,8 +54,13 @@ jobs:
docker-build:
name: docker-build
needs: setup_matrix
needs: [setup_matrix, changes]
if: >-
needs.changes.outputs.python == 'true' ||
needs.changes.outputs.frontend == 'true' ||
needs.changes.outputs.docker == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
matrix:
build_preset: ${{fromJson(needs.setup_matrix.outputs.matrix_config)}}
@@ -50,14 +77,7 @@ jobs:
with:
persist-credentials: false
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Docker Environment
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
uses: ./.github/actions/setup-docker
with:
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
@@ -65,11 +85,9 @@ jobs:
build: "true"
- name: Setup supersetbot
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
uses: ./.github/actions/setup-supersetbot/
- name: Build Docker Image
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -95,7 +113,7 @@ jobs:
# in the context of push (using multi-platform build), we need to pull the image locally
- name: Docker pull
if: github.event_name == 'push' && (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker)
if: github.event_name == 'push'
run: |
for i in 1 2 3; do
docker pull $IMAGE_TAG && break
@@ -103,7 +121,6 @@ jobs:
done
- name: Print docker stats
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
run: |
echo "SHA: ${{ github.sha }}"
echo "IMAGE: $IMAGE_TAG"
@@ -111,7 +128,7 @@ jobs:
docker history $IMAGE_TAG
- name: docker-compose sanity check
if: (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker) && matrix.build_preset == 'dev'
if: matrix.build_preset == 'dev'
shell: bash
env:
BUILD_PRESET: ${{ matrix.build_preset }}
@@ -124,20 +141,16 @@ jobs:
docker-compose-image-tag:
# Run this job only on pushes to master (not for PRs)
# goal is to check that building the latest image works, not required for all PR pushes
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
needs: changes
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.changes.outputs.docker == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Docker Environment
if: steps.check.outputs.docker
uses: ./.github/actions/setup-docker
with:
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
@@ -145,7 +158,6 @@ jobs:
build: "false"
install-docker-compose: "true"
- name: docker-compose sanity check
if: steps.check.outputs.docker
shell: bash
run: |
docker compose -f docker-compose-image-tag.yml up superset-init --exit-code-from superset-init

View File

@@ -19,6 +19,7 @@ concurrency:
jobs:
pre-commit:
runs-on: ubuntu-24.04
timeout-minutes: 20
strategy:
matrix:
python-version: ["current", "previous", "next"]

View File

@@ -29,6 +29,7 @@ concurrency:
jobs:
changes:
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
@@ -51,6 +52,7 @@ jobs:
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
# Somehow one test flakes on 24.04 for unknown reasons, this is the only GHA left on 22.04
runs-on: ubuntu-22.04
timeout-minutes: 30
permissions:
contents: read
pull-requests: read
@@ -170,6 +172,7 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-22.04
timeout-minutes: 30
permissions:
contents: read
pull-requests: read

View File

@@ -20,6 +20,7 @@ concurrency:
jobs:
test-superset-extensions-cli-package:
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
matrix:
python-version: ["previous", "current", "next"]

View File

@@ -22,6 +22,7 @@ permissions:
jobs:
frontend-build:
runs-on: ubuntu-24.04
timeout-minutes: 30
outputs:
should-run: ${{ steps.check.outputs.frontend }}
steps:
@@ -74,6 +75,7 @@ jobs:
shard: [1, 2, 3, 4, 5, 6, 7, 8]
fail-fast: false
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Download Docker Image Artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
@@ -103,6 +105,7 @@ jobs:
needs: [sharded-jest-tests]
if: needs.frontend-build.outputs.should-run == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
id-token: write
steps:
@@ -144,6 +147,7 @@ jobs:
needs: frontend-build
if: needs.frontend-build.outputs.should-run == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Download Docker Image Artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
@@ -168,6 +172,7 @@ jobs:
needs: frontend-build
if: needs.frontend-build.outputs.should-run == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Download Docker Image Artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
@@ -187,6 +192,7 @@ jobs:
needs: frontend-build
if: needs.frontend-build.outputs.should-run == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
- name: Download Docker Image Artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8

View File

@@ -25,6 +25,7 @@ concurrency:
jobs:
changes:
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
@@ -48,6 +49,7 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-22.04
timeout-minutes: 30
continue-on-error: true
permissions:
contents: read

View File

@@ -16,6 +16,7 @@ concurrency:
jobs:
changes:
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
@@ -36,6 +37,7 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
id-token: write
env:
@@ -121,6 +123,7 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
id-token: write
strategy:
@@ -179,6 +182,7 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
id-token: write
env:
@@ -222,3 +226,25 @@ jobs:
verbose: true
use_oidc: true
slug: apache/superset
# Stable required-status-check anchor for the matrix-based test-postgres job.
# It is gated on change detection, so on non-Python PRs it is skipped and
# never produces its `test-postgres (current)` context (a job-level skip
# happens before matrix expansion). This always-running job reports a single
# context branch protection can require: it passes when test-postgres
# succeeded or was skipped, and fails only on a real failure.
test-postgres-required:
needs: [changes, test-postgres]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check test-postgres result
env:
RESULT: ${{ needs.test-postgres.result }}
run: |
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
echo "test-postgres did not pass (result: $RESULT)"
exit 1
fi
echo "test-postgres result: $RESULT"

View File

@@ -17,6 +17,7 @@ concurrency:
jobs:
changes:
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
@@ -37,6 +38,7 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
id-token: write
env:
@@ -99,6 +101,7 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
id-token: write
env:

View File

@@ -17,6 +17,7 @@ concurrency:
jobs:
changes:
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
@@ -37,6 +38,7 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
id-token: write
strategy:
@@ -74,3 +76,25 @@ jobs:
verbose: true
use_oidc: true
slug: apache/superset
# Stable required-status-check anchor. `unit-tests` is a matrix job gated on
# change detection, so on non-Python PRs it is skipped and never produces its
# `unit-tests (current)` context (a job-level skip happens before matrix
# expansion). This always-running job reports a single context that branch
# protection can require: it passes when unit-tests succeeded or was skipped,
# and fails only on a real failure.
unit-tests-required:
needs: [changes, unit-tests]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check unit-tests result
env:
RESULT: ${{ needs.unit-tests.result }}
run: |
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
echo "unit-tests did not pass (result: $RESULT)"
exit 1
fi
echo "unit-tests result: $RESULT"

View File

@@ -22,6 +22,7 @@ concurrency:
jobs:
app-checks:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

View File

@@ -0,0 +1,178 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# SIP: Session/token lifecycle and extension supply-chain hardening
## [DRAFT — proposal for discussion]
This document seeds a formal SIP. It collects a set of related security-hardening
items that surfaced during a security review and that are **not** safely
shippable as isolated, untested PRs because each needs a behavior-sensitive
change, a schema migration, or coordination across components. Each section
states the gap, a proposed design, the compatibility considerations, and the
rejected alternatives.
Several smaller findings from the same review were already addressed as
standalone PRs (e.g. async-query JWT expiration, force-password-change, the
`EXTENSION_BLOCKLIST` gate). This SIP covers only the items that warrant a
design discussion.
## Motivation
Superset's authentication is delegated to Flask-AppBuilder (FAB) and Flask-Login.
The defaults are reasonable, but a few session/token-lifecycle behaviors fall
short of ASVS L1 expectations, and the extension system loads third-party code
with no supply-chain gating beyond a static blocklist. Addressing these in a
coordinated way (rather than piecemeal) avoids half-measures that give a false
sense of security.
---
## Part A — Session and token lifecycle
### A1. Session fixation: regenerate the session on login (ASVS 7.2.4, CWE-384)
**Gap.** `on_user_login()` does audit logging only; neither it nor FAB's
`AuthDBView.login` rotates the session on a successful authentication.
**Nuance.** With Superset's **default** signed client-side cookie sessions
(`SESSION_SERVER_SIDE = False`), classic fixation is largely mitigated: the
cookie's signed *content* changes the moment `_user_id` is written at login, so
a pre-seeded cookie cannot be reused post-auth. The real exposure is for
deployments running **server-side sessions** (`flask-session`), where the cookie
holds a stable session id that survives the privilege transition.
**Proposed design.**
- On the `user_logged_in` signal, regenerate the session identity:
- server-side sessions: rotate the session id (snapshot the session dict,
clear, restore) so any pre-login id is invalidated;
- client-side sessions: no-op (already safe).
- Optionally expose `SESSION_PROTECTION` (`"strong"`) as a documented opt-in,
noting its UX tradeoff (logout on client-identifier change).
**Compatibility.** Must preserve the post-login redirect target and any keys the
auth flow stores pre-login (OAuth `state`, `next`). Needs end-to-end testing of
DB, OAuth, and LDAP login with both session backends.
### A2. Terminate active sessions when an account is disabled or deleted (ASVS 7.4.2, CWE-613)
**Gap.** `post_update`/`post_delete` on the user API audit-log only. A disabled
or deleted user keeps access until Flask-Login's passive `is_active` check fires
on their next request — and for client-side cookie sessions there is no
server-side session to delete at all.
**Proposed design — a per-user invalidation epoch (works for both session
backends).**
- Add `sessions_invalidated_at` (UTC timestamp) per user (on `UserAttribute`, or
a dedicated column; migration required).
- Stamp the login time into the session at `on_user_login` (`session["_login_at"]`).
- A `before_request` hook compares `session["_login_at"]` to the user's
`sessions_invalidated_at`; if the session predates it, force a logout.
- Set `sessions_invalidated_at = now()` from `post_update` (when `active`
flips to `False`) and `post_delete`.
This invalidates outstanding sessions regardless of session backend, without
needing to enumerate or index server-side session stores by user.
**Compatibility.** Adds one indexed timestamp column and a cheap per-request
comparison (only when the user has a non-null epoch). Backwards compatible
(null epoch ⇒ no effect).
### A3. Guest-token revocation for embedded dashboards (ASVS 7.4.1, CWE-613)
**Gap.** Guest tokens are self-contained JWTs validated only for signature,
`exp`, and `aud` — there is no revocation. When an admin revokes a guest's
access, existing tokens remain valid until `exp`
(`GUEST_TOKEN_JWT_EXP_SECONDS`, default 5 min).
**Proposed design (per-embedded-dashboard `revoked_before`).**
- Add `guest_token_revoked_before` (timestamp) to the embedded-dashboard model
(migration required).
- In `parse_jwt_guest_token` / `get_guest_user_from_request`, reject a token
whose `iat` is earlier than the resource's `guest_token_revoked_before`.
- Surface a "revoke active guest sessions" admin action that sets the timestamp.
**Lower-effort alternative (already partly true).** The 5-minute default `exp`
is the de-facto mitigation; rotating `GUEST_TOKEN_JWT_SECRET` is the existing
all-tokens "break glass". Document both, and keep `exp` short.
**Compatibility.** Requires `iat` in guest tokens (add it to
`create_guest_access_token`); the `revoked_before` check is opt-in per dashboard.
---
## Part B — Extension supply chain (ASVS 15.2.1, CWE-1104)
**Context.** Extensions execute arbitrary Python. A static `EXTENSION_BLOCKLIST`
(block by id or `id@version`) has already shipped, letting operators refuse a
known-bad extension. This part covers the larger, ongoing controls.
**Gaps.**
- No check of an extension (or its declared `dependencies`) against a known
vulnerability database.
- No maximum-age / minimum-version policy.
**Proposed design.**
- Pluggable advisory source (`EXTENSION_ADVISORY_PROVIDER`) queried at load time
against OSV / the GitHub Advisory Database, with results cached.
- An allowlist/min-version policy (`EXTENSION_VERSION_POLICY`) checked alongside
the existing blocklist in `get_extensions()`.
- A "fail open vs fail closed" config switch so offline/air-gapped deployments
aren't broken by an unreachable advisory source.
**Compatibility.** Off by default (no provider configured ⇒ today's behavior).
Network egress and caching need design; this is the largest item here and likely
its own follow-up SIP.
---
## Phasing
1. **A2 (session invalidation epoch)** — highest value, self-contained mechanism;
good first implementation.
2. **A1 (login session regeneration)** — needs cross-backend login testing.
3. **A3 (guest-token revocation)** — schema + admin UI.
4. **Part B** — advisory-source integration; likely a separate SIP.
## Already shipped (related, out of scope here)
- Async-query JWT `exp` + sliding refresh.
- Force-password-change-on-first-use (opt-in) + password complexity policy.
- `EXTENSION_BLOCKLIST` static gate.
- ZIP total-size cap / zero-division guard; SSH `server_address` validation;
embedded `Sec-Fetch-Dest` check.
## Rejected alternatives
- **Rely solely on `SESSION_PROTECTION="strong"`** for A1/A2 — blunt (logs users
out on benign network changes) and doesn't cover account disable/delete.
- **Enumerate server-side session stores by user** for A2 — backend-specific and
impossible for client-side cookie sessions; the invalidation-epoch approach is
backend-agnostic.
- **Shorten guest `exp` to seconds** instead of A3 — degrades embedded UX and
still leaves a window.
## Open questions
- Where should `sessions_invalidated_at` live — `UserAttribute` or a new `ab_user`
column (FAB-owned table)?
- Should A1 default to active for server-side-session deployments, or stay
opt-in?
- Should Part B be split into its own SIP given its scope and network/egress
implications?

View File

@@ -30,7 +30,6 @@ const mockUserRegistrations = Array.from({ length: 5 }, (_, i) => ({
last_name: `Test${i}`,
email: `user${i}@test.com`,
registration_date: new Date(2025, 2, 25, 11, 4, 32 + i).toISOString(),
registration_hash: `hash${i}`,
}));
fetchMock.get(userRegistrationsEndpoint, {
@@ -53,4 +52,10 @@ describe('UserRegistrations', () => {
const calls = fetchMock.callHistory.calls(userRegistrationsEndpoint);
expect(calls.length).toBeGreaterThan(0);
});
test('does not expose the registration hash', async () => {
expect(await screen.findByText('User registrations')).toBeVisible();
// The activation hash is a bearer token and must not be shown in the UI.
expect(screen.queryByText('Registration hash')).not.toBeInTheDocument();
});
});

View File

@@ -40,7 +40,6 @@ export type UserRegistration = {
last_name: string;
email: string;
registration_date: string;
registration_hash: string;
};
export default function UserRegistrations() {
@@ -110,12 +109,6 @@ export default function UserRegistrations() {
Header: t('Email'),
Cell: ({ row: { original } }: any) => original.email,
},
{
accessor: 'registration_hash',
id: 'registration_hash',
Header: t('Registration hash'),
Cell: ({ row: { original } }: any) => original.registration_hash,
},
{
accessor: 'registration_date',
id: 'registration_date',
@@ -177,13 +170,6 @@ export default function UserRegistrations() {
input: 'search',
operator: ListViewFilterOperator.Contains,
},
{
Header: t('Registration hash'),
key: 'registration_hash',
id: 'registration_hash',
input: 'search',
operator: ListViewFilterOperator.Contains,
},
{
Header: t('Registration date'),
key: 'registration_date',

View File

@@ -67,6 +67,25 @@ Copy `config.example.json` to `config.json` and adjust the values for your envir
Configuration via environment variables is also supported which can be helpful in certain contexts, e.g., deployment. `src/config.ts` can be consulted to see the full list of supported values.
### Restricting WebSocket origins
To mitigate Cross-Site WebSocket Hijacking, set `allowedOrigins` (or the
`ALLOWED_ORIGINS` environment variable, comma-separated) to the list of origins
permitted to open WebSocket connections, e.g. the origin Superset is served
from:
```json
{
"allowedOrigins": ["https://superset.example.com"]
}
```
The `Origin` header of each upgrade request must exactly match one of the
configured values. When `allowedOrigins` is empty (the default) the check is
skipped and any origin is accepted; a single `"*"` entry explicitly allows any
origin. Setting this is recommended for production deployments, especially when
the JWT cookie uses `SameSite=None`.
## Superset Configuration
Configure the Superset Flask app to enable global async queries (in `superset_config.py`):

View File

@@ -18,5 +18,6 @@
"redisStreamPrefix": "async-events-",
"jwtAlgorithms": ["HS256"],
"jwtSecret": "CHANGE-ME",
"jwtCookieName": "async-token"
"jwtCookieName": "async-token",
"allowedOrigins": []
}

View File

@@ -547,6 +547,105 @@ describe('server', () => {
expect(socketDestroySpy).not.toHaveBeenCalled();
expect(wssUpgradeSpy).toHaveBeenCalled();
});
describe('origin validation', () => {
afterEach(() => {
server.opts.allowedOrigins = [];
});
const getRequestWithOrigin = (
token: string,
origin?: string,
): http.IncomingMessage => {
const request = new http.IncomingMessage(new net.Socket());
request.method = 'GET';
request.headers = { cookie: `${config.jwtCookieName}=${token}` };
if (origin) request.headers.origin = origin;
request.url = 'http://localhost';
return request;
};
test('rejects upgrade from a disallowed origin', () => {
server.opts.allowedOrigins = ['https://superset.example.com'];
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
const request = getRequestWithOrigin(
validToken,
'https://evil.example',
);
server.httpUpgrade(request, socket, Buffer.alloc(5));
expect(socketDestroySpy).toHaveBeenCalled();
expect(wssUpgradeSpy).not.toHaveBeenCalled();
});
test('rejects upgrade with no origin when an allowlist is set', () => {
server.opts.allowedOrigins = ['https://superset.example.com'];
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
const request = getRequestWithOrigin(validToken);
server.httpUpgrade(request, socket, Buffer.alloc(5));
expect(socketDestroySpy).toHaveBeenCalled();
expect(wssUpgradeSpy).not.toHaveBeenCalled();
});
test('allows upgrade from an allowed origin', () => {
server.opts.allowedOrigins = ['https://superset.example.com'];
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
const request = getRequestWithOrigin(
validToken,
'https://superset.example.com',
);
server.httpUpgrade(request, socket, Buffer.alloc(5));
expect(socketDestroySpy).not.toHaveBeenCalled();
expect(wssUpgradeSpy).toHaveBeenCalled();
});
});
});
describe('isOriginAllowed', () => {
const makeRequest = (origin?: string): http.IncomingMessage => {
const request = new http.IncomingMessage(new net.Socket());
if (origin) request.headers.origin = origin;
return request;
};
afterEach(() => {
server.opts.allowedOrigins = [];
});
test('allows any origin when allowlist is empty', () => {
server.opts.allowedOrigins = [];
expect(server.isOriginAllowed(makeRequest('https://anything'))).toBe(
true,
);
expect(server.isOriginAllowed(makeRequest())).toBe(true);
});
test('allows any origin when allowlist contains a wildcard', () => {
server.opts.allowedOrigins = ['*'];
expect(server.isOriginAllowed(makeRequest('https://anything'))).toBe(
true,
);
});
test('allows an exact-match origin', () => {
server.opts.allowedOrigins = ['https://a.example', 'https://b.example'];
expect(server.isOriginAllowed(makeRequest('https://b.example'))).toBe(
true,
);
});
test('rejects a non-matching or missing origin', () => {
server.opts.allowedOrigins = ['https://a.example'];
expect(server.isOriginAllowed(makeRequest('https://evil.example'))).toBe(
false,
);
expect(server.isOriginAllowed(makeRequest())).toBe(false);
});
});
const setReadyState = (ws: WebSocket, value: typeof ws.readyState) => {

View File

@@ -47,6 +47,7 @@ type ConfigType = {
jwtSecret: string;
jwtCookieName: string;
jwtChannelIdKey: string;
allowedOrigins: string[];
socketResponseTimeoutMs: number;
pingSocketsIntervalMs: number;
gcChannelsIntervalMs: number;
@@ -65,6 +66,7 @@ function defaultConfig(): ConfigType {
jwtSecret: '',
jwtCookieName: 'async-token',
jwtChannelIdKey: 'channel',
allowedOrigins: [],
socketResponseTimeoutMs: 60 * 1000,
pingSocketsIntervalMs: 20 * 1000,
gcChannelsIntervalMs: 120 * 1000,
@@ -99,7 +101,11 @@ function configFromFile(): Partial<ConfigType> {
const isPresent = (s: string) => /\S+/.test(s);
const toNumber = Number;
const toBoolean = (s: string) => s.toLowerCase() === 'true';
const toStringArray = (s: string) => s.split(',');
const toStringArray = (s: string) =>
s
.split(',')
.map(entry => entry.trim())
.filter(entry => entry.length > 0);
function applyEnvOverrides(config: ConfigType): ConfigType {
const envVarConfigSetter: { [envVar: string]: (val: string) => void } = {
@@ -114,6 +120,7 @@ function applyEnvOverrides(config: ConfigType): ConfigType {
(config.redisStreamReadBlockMs = toNumber(val)),
JWT_SECRET: val => (config.jwtSecret = val),
JWT_COOKIE_NAME: val => (config.jwtCookieName = val),
ALLOWED_ORIGINS: val => (config.allowedOrigins = toStringArray(val)),
SOCKET_RESPONSE_TIMEOUT_MS: val =>
(config.socketResponseTimeoutMs = toNumber(val)),
PING_SOCKETS_INTERVAL_MS: val =>

View File

@@ -389,6 +389,33 @@ export const httpRequest = (
}
};
/**
* Validates the `Origin` header of a WebSocket upgrade request against the
* configured `allowedOrigins` list, mitigating Cross-Site WebSocket Hijacking.
*
* When `allowedOrigins` is empty the check is skipped (preserving existing
* behavior); a single `'*'` entry explicitly allows any origin. Otherwise the
* request's `Origin` must exactly match one of the configured origins.
*/
export const isOriginAllowed = (request: http.IncomingMessage): boolean => {
const { allowedOrigins } = opts;
if (!allowedOrigins || allowedOrigins.length === 0) {
return true;
}
if (allowedOrigins.includes('*')) {
return true;
}
// `origin` is typed as `string | string[] | undefined`; only a single,
// unambiguous string header is acceptable for an exact-match comparison.
const origin = request.headers.origin;
if (typeof origin !== 'string') {
return false;
}
return allowedOrigins.includes(origin);
};
/**
* HTTP `upgrade` event handler, called via httpServer
*/
@@ -397,6 +424,16 @@ export const httpUpgrade = (
socket: net.Socket,
head: Buffer,
) => {
if (!isOriginAllowed(request)) {
logger.error(
`Rejecting WebSocket upgrade from disallowed origin: ${
request.headers.origin || '(none)'
}`,
);
socket.destroy();
return;
}
try {
readChannelId(request);
} catch (err) {

View File

@@ -1026,6 +1026,15 @@ class ChartRestApi(BaseSupersetModelRestApi):
return self.response(200, result="OK")
def _pre_related_check(self, column_name: str) -> Optional[Response]:
"""Restrict the owners related field to users with write access."""
if (
column_name == "owners"
and not security_manager.can_access_all_datasources()
):
return self.response_403()
return None
@expose("/warm_up_cache", methods=("PUT",))
@protect()
@safe

View File

@@ -370,6 +370,11 @@ class UserRegistrationsRestAPI(BaseSupersetModelRestApi):
resource_name = "security/user_registrations"
datamodel = SQLAInterface(RegisterUser)
allow_browser_login = True
# NOTE: registration_hash is intentionally excluded from both list_columns
# and search_columns. It is a bearer token for the
# /register/activation/<hash> flow; exposing it in API responses (and thus
# logs/caches) or allowing it to be filtered on would let a holder activate
# the pending account.
list_columns = [
"id",
"username",
@@ -377,5 +382,11 @@ class UserRegistrationsRestAPI(BaseSupersetModelRestApi):
"first_name",
"last_name",
"registration_date",
"registration_hash",
]
search_columns = [
"username",
"email",
"first_name",
"last_name",
"registration_date",
]

View File

@@ -2436,3 +2436,11 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase):
security_manager.add_permission_role(alpha_role, write_tags_perm)
security_manager.add_permission_role(alpha_role, tag_charts_perm)
def test_related_owners_allowed_for_write_user(self):
"""
Chart API: GET /api/v1/chart/related/owners returns 200 for Admin.
"""
self.login(ADMIN_USERNAME)
rv = self.client.get("api/v1/chart/related/owners")
assert rv.status_code == 200