mirror of
https://github.com/apache/superset.git
synced 2026-07-25 08:02:28 +00:00
Compare commits
9 Commits
ci/cypress
...
sip/auth-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fd6557d79 | ||
|
|
8dcc7e7eec | ||
|
|
ff5e43c8a0 | ||
|
|
bdb081329f | ||
|
|
aa547da960 | ||
|
|
966c243db6 | ||
|
|
696705794b | ||
|
|
41572dbf9d | ||
|
|
5ba60d51fd |
@@ -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
|
||||
|
||||
35
.github/workflows/codeql-analysis.yml
vendored
35
.github/workflows/codeql-analysis.yml
vendored
@@ -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}}"
|
||||
|
||||
54
.github/workflows/docker.yml
vendored
54
.github/workflows/docker.yml
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/pre-commit.yml
vendored
1
.github/workflows/pre-commit.yml
vendored
@@ -19,6 +19,7 @@ concurrency:
|
||||
jobs:
|
||||
pre-commit:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["current", "previous", "next"]
|
||||
|
||||
3
.github/workflows/superset-e2e.yml
vendored
3
.github/workflows/superset-e2e.yml
vendored
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
6
.github/workflows/superset-frontend.yml
vendored
6
.github/workflows/superset-frontend.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/superset-playwright.yml
vendored
2
.github/workflows/superset-playwright.yml
vendored
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
24
.github/workflows/superset-python-unittest.yml
vendored
24
.github/workflows/superset-python-unittest.yml
vendored
@@ -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"
|
||||
|
||||
1
.github/workflows/superset-websocket.yml
vendored
1
.github/workflows/superset-websocket.yml
vendored
@@ -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
|
||||
|
||||
178
docs/sip/auth-session-and-extension-supply-chain-hardening.md
Normal file
178
docs/sip/auth-session-and-extension-supply-chain-hardening.md
Normal 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?
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`):
|
||||
|
||||
@@ -18,5 +18,6 @@
|
||||
"redisStreamPrefix": "async-events-",
|
||||
"jwtAlgorithms": ["HS256"],
|
||||
"jwtSecret": "CHANGE-ME",
|
||||
"jwtCookieName": "async-token"
|
||||
"jwtCookieName": "async-token",
|
||||
"allowedOrigins": []
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user