Skip to content

Flask flash messages are written but never consumed, growing the session cookie and hiding login error #44541

Description

@dr29bart

Bug description

#35237 (abc2d46fed, in 6.1.0) removed the only consumer of Flask's flash queue but left the
producers in place. get_flashed_messages() is what pops _flashes out of the session; it used to
run on every SPA render:

--- a/superset/views/base.py
+++ b/superset/views/base.py
 def common_bootstrap_payload() -> dict[str, Any]:
-    return {
-        **cached_common_bootstrap_data(utils.get_user_id(), get_locale()),
-        "flash_messages": get_flashed_messages(with_categories=True),
-    }
+    return cached_common_bootstrap_data(utils.get_user_id(), get_locale())

It now appears nowhere in superset/, superset/templates/ or superset-frontend/src, and the same
commit deleted the frontend FlashProvider that displayed the messages. Flask-AppBuilder's auth
views still flash on every failed login, as do
superset/security/session_invalidation.py:132 and superset/security/password_change.py:176.

So _flashes grows for the life of the session and nothing ever renders it. Two consequences:

  1. The default client-side session cookie grows without bound (SESSION_SERVER_SIDE = False),
    about 57 bytes per failed login attempt, carried on every request.
  2. Login failures are invisible. superset-frontend/src/pages/Login/index.tsx papers over the
    DB-credentials path by fabricating a message from sessionStorage, behind a TODO admitting the
    gap. There is no equivalent for OAuth, session invalidation, or forced password change.

Minimal reproduction

Default config, AUTH_TYPE = AUTH_DB, SESSION_SERVER_SIDE = False:

  1. Go to /login/ and submit wrong credentials three times.

  2. Decode the session cookie — Flask signs but does not encrypt it, so no secret is needed:

    import base64, json, zlib
    
    cookie = "<value of the session cookie>"
    raw = cookie.split(".")[1] if cookie.startswith(".") else cookie.split(".")[0]
    data = base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))
    print(json.loads(zlib.decompress(data) if cookie.startswith(".") else data)["_flashes"])

Observed: three copies of ["warning", "Invalid login. Please try again."], retained for the life
of the session, none of them ever shown.
Expected: the queue is drained when the login page renders, as it was before 6.1.0.

Why it matters beyond cookie size

Under AUTH_TYPE = AUTH_OAUTH the same accumulation stacks with Authlib's stale _state_* handshake
entries (an Authlib/FAB issue, not this one). On the fourth consecutive refusal by the IdP, the 302
carrying Set-Cookie + Location + CSP exceeded ingress-nginx's default 4k proxy_buffer_size and
the user got HTTP 502 instead of a login page — having never been told why any attempt failed.
Past roughly six entries the cookie also crosses the browser's 4096-byte cap and the whole session,
csrf_token included, is silently dropped.

Suggested fix

Drain and display at the login view, which addresses both consequences at once.
superset/views/auth.py:

from flask import g, get_flashed_messages, redirect

    @expose("/")
    @no_cache
    def login(self, provider: Optional[str] = None) -> WerkzeugResponse:
        if g.user is not None and g.user.is_authenticated:
            return redirect(self.appbuilder.get_url_for_index)

        return super().render_app_template(
            {"auth_messages": get_flashed_messages(with_categories=True)}
        )

render_app_template's first argument is extra_bootstrap_data, which get_spa_payload merges per
request. It must go there and not back into common_bootstrap_payload(), whose
cached_common_bootstrap_data is @cache_manager.cache.memoize(timeout=60) — per-user messages
there would bleed between users, which is presumably why #35237 deleted rather than relocated them.

Then render bootstrapData.auth_messages in superset-frontend/src/pages/Login/index.tsx (replacing
the sessionStorage hack) and add auth_messages?: [string, string][] to BootstrapData in
superset-frontend/src/types/bootstrapTypes.ts.

The alternative — removing the remaining flash() calls instead — bounds the cookie but leaves users
with no feedback on a failed login.

Screenshots/recordings

No response

Superset version

master / latest-dev

Python version

3.11

Node version

18 or greater

Browser

Chrome

Additional context

k8s ingress controller logs:
[error] 1103#1103: *10079930 upstream sent too big header while reading response header from upstream

  • No feature flags involved. SESSION_SERVER_SIDE = False is what puts the growth in the cookie
    rather than a server-side store; enabling server-side sessions hides the symptom but the queue
    still grows in the store.
  • No Python stacktrace: nothing raises. FAB logs the auth error and redirects; the 502 in the OAuth
    case comes from ingress-nginx.
  • Separate docs nit, if useful: docs/admin_docs/security/security.mdx says the session cookie "is
    encrypted with the application SECRET_KEY and cannot be read by the client". It is signed, not
    encrypted — the snippet above needs no secret.

related?
#30302

Checklist

  • I have searched Superset docs and Slack and didn't find a solution to my problem.
  • I have searched the GitHub issue tracker and didn't find a similar bug report.
  • I have checked Superset's logs for errors and if I found a relevant Python stacktrace, I included it here as text in the "additional context" section.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions