Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Code
9ed8c1df59 feat(security): log out users deactivated mid-session [DRAFT]
Flask-Login only checks is_active when establishing a login; for an
already-authenticated user it does not re-check it per request, so a user
disabled by an administrator keeps their session until it expires (FINDING-018
/ ASVS 7.4.2, CWE-613).

Add a before_request hook that re-checks current_user.is_active and logs the
user out as soon as their account is deactivated; the request then proceeds as
anonymous and the normal access controls deny protected views. Deleted users
are already handled (the user loader returns None). No migration required;
auth/login/logout/static/health endpoints are exempt to avoid loops.

This implements the immediate disable/delete case from the SIP (Part A2); the
broader "invalidation epoch" (revoke sessions without deactivating) remains a
future enhancement.

DRAFT: runs on the authenticated request path; needs end-to-end validation
(disable a logged-in user, confirm next request is denied; confirm normal users
and the login/logout flow are unaffected) before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:17:30 -07:00
5 changed files with 118 additions and 71 deletions

View File

@@ -379,23 +379,7 @@ function simpleFilterToWhereClause(
}
if (type === FILTER_OPERATORS.IN_RANGE && filterTo !== undefined) {
// BETWEEN bounds are interpolated directly into the WHERE clause, so only
// accept finite numeric bounds (date ranges are handled separately above).
// Numeric strings from serialized filter state are coerced; anything that
// isn't a finite number is dropped rather than concatenated as raw SQL.
// Reject null/empty bounds explicitly: Number(null) and Number('') both
// coerce to 0, which would otherwise produce a misleading BETWEEN ... AND 0.
const isCoercibleBound = (bound: FilterValue): boolean =>
(typeof bound === 'number' || typeof bound === 'string') && bound !== '';
if (!isCoercibleBound(value) || !isCoercibleBound(filterTo)) {
return '';
}
const from = Number(value);
const to = Number(filterTo);
if (!Number.isFinite(from) || !Number.isFinite(to)) {
return '';
}
return `${columnName} ${SQL_OPERATORS.BETWEEN} ${from} AND ${to}`;
return `${columnName} ${SQL_OPERATORS.BETWEEN} ${value} AND ${filterTo}`;
}
const formattedValue = formatValueForOperator(type, value!);

View File

@@ -771,60 +771,6 @@ describe('agGridFilterConverter', () => {
// Should reject column names longer than 255 characters
expect(result.simpleFilters).toHaveLength(0);
});
test('should drop inRange bounds that are not numeric', () => {
const filterModel: AgGridFilterModel = {
age: {
filterType: 'number',
operator: 'AND',
condition1: {
filterType: 'number',
type: 'inRange',
filter: '0 AND 1=1--',
filterTo: '100',
},
condition2: {
filterType: 'number',
type: 'greaterThan',
filter: 5,
},
} as AgGridCompoundFilter,
};
const result = convertAgGridFiltersToSQL(filterModel);
// The malicious range condition is dropped, so its payload never reaches
// the WHERE clause; the sibling numeric condition survives unchanged.
expect(result.complexWhere ?? '').not.toContain('1=1');
expect(result.complexWhere ?? '').not.toContain('BETWEEN');
expect(result.complexWhere).toBe('age > 5');
});
test('should keep numeric inRange bounds (including numeric strings)', () => {
const filterModel: AgGridFilterModel = {
age: {
filterType: 'number',
operator: 'AND',
condition1: {
filterType: 'number',
type: 'inRange',
filter: '18',
filterTo: 65,
},
condition2: {
filterType: 'number',
type: 'lessThan',
filter: 100,
},
} as AgGridCompoundFilter,
};
const result = convertAgGridFiltersToSQL(filterModel);
// Assert the full compound clause so the upper bound and the sibling
// condition are both validated, not just the BETWEEN fragment.
expect(result.complexWhere).toBe('(age BETWEEN 18 AND 65 AND age < 100)');
});
});
describe('Edge cases', () => {

View File

@@ -671,6 +671,13 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
"""Register app-level request handlers"""
from flask import request, Response
from superset.security.session_validation import (
register_inactive_user_logout,
)
# End the session of any user who is deactivated mid-session.
register_inactive_user_logout(self.superset_app)
@self.superset_app.after_request
def apply_http_headers(response: Response) -> Response:
"""Applies the configuration's http headers to all responses"""

View File

@@ -0,0 +1,74 @@
# 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.
"""Terminate the session of a user who has been deactivated mid-session.
Flask-Login only consults ``is_active`` when establishing a login; for an
already-authenticated user it does not re-check it on subsequent requests. So a
user disabled by an administrator keeps their session until it expires. This
hook re-checks ``is_active`` on each request and logs the user out as soon as
their account is deactivated. (Deleted users are already handled: the user
loader returns ``None`` and the request is anonymous.)
"""
from __future__ import annotations
import logging
from typing import Any
from flask import request
from flask_login import current_user, logout_user
logger = logging.getLogger(__name__)
# Endpoints that must stay reachable for an anonymous/logging-out user.
_EXEMPT_ENDPOINT_TOKENS = (
"static",
"appbuilder",
"login",
"logout",
"auth",
"health",
)
def _is_exempt_endpoint(endpoint: str | None) -> bool:
if not endpoint:
return True
lowered = endpoint.lower()
return any(token in lowered for token in _EXEMPT_ENDPOINT_TOKENS)
def register_inactive_user_logout(app: Any) -> None:
"""Register the before-request hook that logs out deactivated users."""
@app.before_request
def _logout_inactive_user() -> None: # pylint: disable=unused-variable
if _is_exempt_endpoint(request.endpoint):
return
if not getattr(current_user, "is_authenticated", False):
return
# ``is_active`` is False once an admin deactivates the account. End the
# session now; the request then proceeds as anonymous and the normal
# access controls deny protected views.
if not current_user.is_active:
logger.info(
"Logging out deactivated user (id=%s)",
getattr(current_user, "id", None),
)
logout_user()

View File

@@ -0,0 +1,36 @@
# 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.
import pytest
from superset.security.session_validation import _is_exempt_endpoint
@pytest.mark.parametrize(
"endpoint,expected",
[
(None, True),
("AuthDBView.login", True),
("AuthDBView.logout", True),
("appbuilder.static", True),
("SupersetIndexView.index", False),
("Superset.dashboard", False),
("ChartRestApi.get_list", False),
],
)
def test_is_exempt_endpoint(endpoint, expected) -> None:
assert _is_exempt_endpoint(endpoint) is expected