Skip to content

fix(embedded): fail closed when the guest-user check can't resolve a principal - #43896

Merged
rusackas merged 4 commits into
masterfrom
followup-error-sanitization-fail-open
Oct 13, 2026
Merged

rusackas merged 4 commits into
masterfrom
followup-error-sanitization-fail-open

Conversation

@eschutho

@eschutho eschutho commented Sep 4, 2026 •

Copy link
Copy Markdown
Member

SUMMARY

Follow-up addressing the review of #43834. That PR guarded is_sanitization_required() so a raising user loader could no longer turn error responses into bare 500s. On review it was pointed out that the guard fails open — it weakens the very feature it protects — and that the same "an error handler must never itself raise" invariant still has gaps elsewhere. This PR addresses those three follow-ups.

1. The fail-open guard could disclose to real embedded guests

is_sanitization_required() returned False ("do not sanitize") whenever security_manager.is_guest_user() raised, on the assumption that an unresolvable principal is "by definition not a guest." That assumption is false — the check can raise for a resolvable guest:

  • get_guest_user_from_request calls get_guest_user_from_token(...) outside its try/except, and get_guest_user_from_token calls find_role(...) — a metadata-DB round trip. A valid, unrevoked guest token on a request whose DB session is already broken (a PendingRollbackError, precisely because the error being handled is itself a SQLAlchemyError) makes find_role raise.
  • is_guest_user consults is_feature_enabled("EMBEDDED_SUPERSET") first, which routes through a deployment-supplied hook that can dereference g.user and raise.

In either case a genuine embedded guest received the raw engine error instead of a redacted message.

The fix makes the fallback incapable of raising and fails closed when a token is actually present: on failure, inside a request context it falls back to whether the request carries a guest token (reading the token header/form field cannot raise). Anyone presenting a token is redacted; a genuinely anonymous request keeps its error, so ordinary failures are not over-sanitized. This is a deliberate availability-over-confidentiality trade-off, and the docstrings (source + the two tests from #43834) are reworded to say so rather than claim a definitional truth.

Celery nuance: sanitize_error_dicts also runs in a Celery worker (tasks/async_queries.py) inside override_user(...), where there is no request context and request.headers would itself raise. The fallback is guarded with flask.has_request_context(); with no request there is no handler-of-a-handler concern and a guest principal may be active via override_user, so it fails closed and redacts rather than leaking into the job payload delivered to the embedded viewer.

2. Resolve once / thread the boolean (N+1, log-spam, half-redaction)

is_sanitization_required() was re-invoked per error, so a 10-error response called a raising loader 11× and emitted 11 WARNING tracebacks — for one response, on every 404 including scanners/health probes. charts/data/api.py also half-redacted: the raw outer is_guest_user() could succeed while the inner guarded sanitize_error_message failed, popping stacktrace but keeping the raw error. The sanitize helpers now take an optional required: bool | None = None (None = compute) so sanitize_superset_errors / sanitize_error_dicts resolve the decision once and thread it down; charts/data/api.py computes the guarded decision once and reuses it for both the stacktrace pop and the message sanitization. Behavior is identical when sanitization is not required.

3. Last-resort handler could still 500

show_unexpected_exception called send_file for 500.html without the try/except FileNotFoundError its three siblings have. A missing 500.html (a webpack artifact, absent in API-only/unbuilt deployments) yielded a bare Werkzeug 500 with no SIP-40 body. It now has the same JSON fallback. (A handler-level decorator enforcing "the error handler must never itself raise" generally would be a broader follow-up; this PR fixes the concrete gap minimally, matching the existing sibling handlers.)

Credit to the reviewer's analysis of the fail-open direction, the resolve-once opportunity, and the last-resort handler gap.

TESTING INSTRUCTIONS

New unit tests (each verified to fail without its corresponding change and pass with it):

  • tests/unit_tests/utils/test_error_sanitization.py
    • a request carrying a guest token whose resolution raises is still redacted (header and form-field variants), and asserts the patched is_guest_user was actually called — locks the fail-closed direction;
    • no-request-context (Celery-style) resolution failure fails closed and redacts;
    • a genuinely anonymous request whose resolution raises is left untouched.
  • tests/unit_tests/charts/test_chart_data_api.py — the half-redaction case (outer succeeds, inner raises) now redacts both stacktrace and error consistently.
  • tests/unit_tests/views/test_error_handling.py — show_unexpected_exception returns a SIP-40 JSON body when 500.html is absent; the class docstring is reworded around the trade-off.

Run:

pytest tests/unit_tests/utils/test_error_sanitization.py \
       tests/unit_tests/views/test_error_handling.py \
       tests/unit_tests/charts/test_chart_data_api.py \
       tests/unit_tests/tasks/test_async_queries.py

All pass; pre-commit (ruff, ruff-format, mypy, pylint) is green on the changed files.

ADDITIONAL INFORMATION

  • Has associated issue:
  • Required feature flags:
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API
  • Removes existing feature or API

@bito-code-review

bito-code-review Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Agent Run #48fb6e

Actionable Suggestions - 0
Additional Suggestions - 2
  • superset/utils/error_sanitization.py - 1
    • form read can raise · Line 132-132
      The docstring claims reading `request.form` "cannot raise", but in Flask 2.3.3 it can: a malformed multipart body raises `BadRequest` and an oversized form raises `RequestEntityTooLarge`. This fallback runs inside the `except` block whose whole purpose is to never break the HTTP error handler, so a raise here propagates and turns the intended error response into a bare 500. Guard the form read with try/except and fail closed.
  • tests/unit_tests/charts/test_chart_data_api.py - 1
    • Test doesn't exercise raise path · Line 468-471
      The `side_effect=[True, RuntimeError(...)]` second value is never consumed: the fixed `_send_chart_response` calls `is_sanitization_required()` once (returns True) and threads `required=True` into `sanitize_error_message`, so `is_guest_user` is never called again. The fail-closed-on-raise scenario in the docstring isn't actually exercised. Use a single raising `side_effect=RuntimeError(...)`.
Review Details
  • Files reviewed - 6 · Commit Range: eee8f3b..eee8f3b
    • superset/charts/data/api.py
    • superset/utils/error_sanitization.py
    • superset/views/error_handling.py
    • tests/unit_tests/charts/test_chart_data_api.py
    • tests/unit_tests/utils/test_error_sanitization.py
    • tests/unit_tests/views/test_error_handling.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@github-actions github-actions Bot added api Related to the REST API preset-io labels Sep 4, 2026
@codecov

codecov Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.23529% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.71%. Comparing base (e10e268) to head (6f36e71).

Files with missing lines Patch % Lines
superset/utils/error_sanitization.py 85.18% 0 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #43896      +/-   ##
==========================================
- Coverage   82.72%   82.71%   -0.01%     
==========================================
  Files        3011     3011              
  Lines      188350   188337      -13     
  Branches    43725    43715      -10     
==========================================
- Hits       155819   155791      -28     
- Misses      29761    29775      +14     
- Partials     2770     2771       +1     
Flag Coverage Δ
hive 36.15% <20.58%> (-0.01%) ⬇️
mysql 55.19% <26.47%> (-0.01%) ⬇️
postgres 55.20% <26.47%> (-0.01%) ⬇️
presto 38.01% <20.58%> (-0.01%) ⬇️
python 86.53% <88.23%> (+<0.01%) ⬆️
sqlite 54.94% <26.47%> (-0.01%) ⬇️
unit 79.87% <88.23%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions github-actions Bot added the requires:rebase Requires rebasing on top of current master label Sep 24, 2026

@rusackas rusackas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really solid tracing of the fail-open gap, and the resolve-once threading is a nice fix for the half-redaction bug too.

One thing before this merges though: bito's right about request.form.get("guest_token") in the fallback. I checked werkzeug's formparser.py/sansio/multipart.py, and an oversized or malformed multipart body raises RequestEntityTooLarge on first access to .form, lazily. That's inside the except block whose entire point is "must never raise," so a request that trips both the original failure and has a bad body escapes right back into the crash class this PR exists to close. Needs its own try/except around the form read (fail closed on any exception there, same as the no-request-context branch).

Smaller one: in test_send_chart_response_does_not_half_redact_guest_query_error, the side_effect=[True, RuntimeError(...)] second value never fires since is_sanitization_required() only runs once now and threads the result down, so the raise path the docstring describes isn't actually exercised. Doesn't block, but worth a single raising side_effect to match what the test claims to cover.

@eschutho

eschutho commented Oct 3, 2026

Copy link
Copy Markdown
Member Author

Thanks @rusackas — both addressed in 197954e (after merging master to clear the conflict):

  1. Form-read fallback: request.form.get("guest_token") is now wrapped in its own try/except and fails closed (redacts) if the body can't be read. I checked the pinned werkzeug (3.1.6): first access to .form can raise RequestEntityTooLarge (over max_content_length / max_form_memory_size / max_form_parts) or ClientDisconnected (truncated body) — both HTTPExceptions — plus OSError from the WSGI server's input stream (gunicorn's body-read errors are OSError subclasses) or the multipart temp-file spool. Parser ValueErrors are already swallowed since the parser runs with silent=True. So the clause catches (HTTPException, OSError). Added a test that drives a real oversized body through it; it raises RequestEntityTooLarge without the guard and passes with it.
  2. Half-redaction test: now uses a single raising side_effect and asserts the lookup happens exactly once, so the raising path the docstring describes is actually exercised.

Comment thread superset/views/error_handling.py Outdated
Comment on lines +328 to +329
except FileNotFoundError:
pass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: send_file can raise other filesystem errors, such as PermissionError or NotADirectoryError, which still escape this last-resort error handler.

Assessment: 🟠 Major · 🔁 Occurrence: Rarely · 🏷️ Possible bug

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/views/error_handling.py
**Line:** 328:329
**Comment:**
	*Possible Bug: `send_file` can raise other filesystem errors, such as `PermissionError` or `NotADirectoryError`, which still escape this last-resort error handler.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, thanks. Fixed in 13c0983: the last-resort show_unexpected_exception handler now catches OSError around send_file rather than only FileNotFoundError. Before the change I confirmed that PermissionError, NotADirectoryError and IsADirectoryError all escaped it. The test is now parametrized over all four. I left the sibling handlers alone: if their send_file raises, Flask routes the error to this handler, so they still get a SIP-40 JSON 500 rather than a bare one (I checked this for the CommandException and SupersetException handlers).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ CodeAnt verified this suggestion was addressed in subsequent commits and marked this thread resolved as of 13c0983.

The fallback now catches OSError from send_file and continues to the SIP-40 JSON error response, covering filesystem errors such as PermissionError and NotADirectoryError.

If that's not right, unresolve this thread and CodeAnt will leave it open.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ CodeAnt verified this suggestion was addressed in subsequent commits and marked this thread resolved as of 6f36e71.

The HTML error-page fallback now catches OSError from send_file and falls through to the SIP-40 JSON error response.

If that's not right, unresolve this thread and CodeAnt will leave it open.

@bito-code-review

Copy link
Copy Markdown
Contributor

The flagged issue is correct: the current error handling in superset/views/error_handling.py is too narrow, as it only catches specific exceptions and allows other filesystem-related errors like PermissionError or NotADirectoryError to escape, potentially leading to unhandled 500 errors. To resolve this, you should broaden the exception handling to catch a wider range of OSError subclasses or simply OSError itself, ensuring the application remains resilient to various filesystem failures.

Would you like me to fetch all other comments on this PR to validate them and implement a comprehensive fix for all identified issues?

@eschutho

eschutho commented Oct 3, 2026

Copy link
Copy Markdown
Member Author

Follow-up in 6eba8b3: I've broadened the form-read guard to except Exception, so it now fails closed on any exception, as you originally asked. My earlier (HTTPException, OSError) turned out to be too narrow. When the server terminates the input stream (for example a chunked body under gunicorn), werkzeug reads the server's stream directly. gunicorn parses chunked trailers lazily inside that read and raises ParseException subclasses (InvalidHeaderName, ObsoleteFolding, …) on a malformed trailer. Those are plain Exceptions, not OSError, so they still escaped as a bare 500. The inline comment records why the catch is deliberately broad.

Tests: the unreadable-body test now uses a body with no token, so it can only pass by failing closed. It's parametrized over a real werkzeug RequestEntityTooLarge and a server stream whose read() raises a plain Exception subclass. The second case fails against the narrow catch and passes with the broad one. I also corrected two comments. One overstated the form fallback: a body whose first parse in the request loader already failed part-way re-parses as an empty form and is treated as token-free (no guest was authenticated on that request). The other overstated the chart-data sanitize block.

@github-actions github-actions Bot removed the requires:rebase Requires rebasing on top of current master label Oct 3, 2026
@bito-code-review

bito-code-review Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Agent Run #0aa653

Actionable Suggestions - 0
Review Details
  • Files reviewed - 4 · Commit Range: eee8f3b..6eba8b3
    • superset/utils/error_sanitization.py
    • tests/unit_tests/charts/test_chart_data_api.py
    • tests/unit_tests/utils/test_error_sanitization.py
    • superset/charts/data/api.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@eschutho
eschutho requested a review from rusackas October 3, 2026 16:22
@eschutho
eschutho force-pushed the followup-error-sanitization-fail-open branch from 6eba8b3 to 17d2b14 Compare October 5, 2026 18:58
@eschutho

eschutho commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

@rusackas I've rebased onto the latest master to pick up the upstream mypy fix in importers/v1/utils.py (#44932). The rebase changed the commit SHAs, so here's where your feedback now lives:

  • Form read in the fallback (blocking): fixed in 17d2b14 (was 6eba8b3). request.form.get("guest_token") has its own guard and fails closed (redacts) on any exception. That covers werkzeug's RequestEntityTooLarge/ClientDisconnected, and also exceptions raised by the WSGI server's input stream, such as gunicorn's ParseException subclasses for a malformed chunked trailer, which aren't OSErrors. Tests use a body with no token and cover both a real werkzeug 413 and a server stream that raises a plain Exception.
  • side_effect nit: fixed in e44af26 (was 197954e). The test uses a single raising side_effect and asserts exactly one lookup.

Could you take another look when you have a moment?

@bito-code-review bito-code-review Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Agent Run #635ee4

Actionable Suggestions - 1
  • superset/utils/error_sanitization.py - 1
Additional Suggestions - 2
  • tests/unit_tests/views/test_error_handling.py - 1
    • Missing local var type hints · Line 426-444
      New test introduces unannotated local variables (`test_app`, `client`, `response`, `payload`). BITO.md adaptive rule 13153 requires explicit type annotations on all local variables in test files. Add them (e.g. `test_app: Flask`, `client: FlaskClient`, `response: Response`, `payload: dict[str, Any]`).
  • superset/views/error_handling.py - 1
    • Duplicated fallback logic · Line 326-329
      This try/except `send_file`/`FileNotFoundError` fallback now appears verbatim in four handlers (`show_superset_exception`, `show_http_exception`, `show_command_exception`, `show_unexpected_exception`). If the fallback semantics change (e.g. also catching `IsADirectoryError` or logging), all four must be updated in lockstep. Consider a shared helper to keep the last-resort behavior in one place.
Review Details
  • Files reviewed - 6 · Commit Range: 581ca2b..17d2b14
    • superset/charts/data/api.py
    • superset/utils/error_sanitization.py
    • superset/views/error_handling.py
    • tests/unit_tests/charts/test_chart_data_api.py
    • tests/unit_tests/utils/test_error_sanitization.py
    • tests/unit_tests/views/test_error_handling.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

Comment on lines +130 to +138
header_name = current_app.config.get("GUEST_TOKEN_HEADER_NAME")
if header_name and request.headers.get(header_name):
return True
try:
# ``request.form`` parses the body lazily on first access. A body
# whose first parse (in the request loader) already failed part-way
# is left partially consumed and re-parses as an empty form; no guest
# was authenticated on such a request, so it is treated as token-free.
return bool(request.form.get("guest_token"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated guest-token detection

The new fallback re-implements the guest-token detection already in security/manager.py (request.headers.get(get_conf()["GUEST_TOKEN_HEADER_NAME"]) or request.form.get("guest_token")). It also diverges: current_app.config.get(...) here vs get_conf()["..."] there. A future change to the header/field name or config access in one place silently desyncs the other. Consider a shared helper.

Code Review Run #635ee4


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm leaving this one as is on purpose. The two reads look alike but have different contracts:

  • request_loader (and the copy in get_guest_user_from_request) is the authentication path. It may raise. For example, an oversized body raises a 413 there, which is the right response.
  • This fallback runs inside the error handler after resolving the principal has already failed, so it must never raise, and it fails closed (redacts) when the body can't be read. That's why it uses current_app.config.get(...) rather than get_conf()[...], which can raise KeyError, and why the form read has its own broad except.

A shared helper would have to pick one of those two behaviours. That would either make this fallback able to raise again, which is the bug this PR closes, or make authentication swallow body errors, which changes auth behaviour and is out of scope here. The things that could drift are the GUEST_TOKEN_HEADER_NAME config key and the guest_token form field. Both are part of the embedded SDK's request format, and the fallback tests build their requests from the same config key and field name.

@eschutho

eschutho commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

I've gone through the automated review comments from the last round:

  • codeant: send_file can raise other filesystem errors: a real gap, fixed in 13c0983. The last-resort handler now catches OSError and falls back to JSON.
  • bito: duplicated send_file fallback across four handlers: handled by that same fix rather than a new shared helper. A sibling handler whose send_file raises is routed to the last-resort handler, which now always returns a SIP-40 body.
  • bito: duplicated guest-token detection: left as is on purpose. The reasoning is in the thread: the authentication-path read may raise, but this fallback must not.
  • bito: type hints on test locals: not changed. test_error_handling.py doesn't annotate locals anywhere (0 of ~47), and mypy infers these.

@rusackas, your review predates the fixes for your feedback and this round. Could you take another look?

@codeant-ai-for-open-source

codeant-ai-for-open-source Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

CodeAnt PR Risk: Low Risk

  • The PR appears safe to merge; when principal resolution fails, guest-token requests are redacted while anonymous requests retain their errors.
  • The HTML error-page fallback catches filesystem errors and returns the SIP-40 JSON response instead of letting the handler fail.
  • Tests cover guest tokens in headers and form bodies, unreadable request bodies, and multiple filesystem errors.

Assessed commit: 6f36e71d7b54

@netlify

netlify Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 6f36e71
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6ac82cdec2c1fd0008035efa
😎 Deploy Preview https://deploy-preview-43896--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

eschutho and others added 4 commits October 8, 2026 23:51
…principal

Follow-up to #43834. That change guarded is_sanitization_required() so a
raising user loader could no longer turn error responses into bare 500s,
but it failed *open*: when the guest-user check raised it returned False
("do not sanitize"), which can disclose raw engine errors to a genuine
embedded guest.

The check can raise for a resolvable guest, not only an unresolvable
anonymous one: get_guest_user_from_token() calls find_role() (a metadata-DB
round trip) outside the request loader's try/except, and is_guest_user()
consults the EMBEDDED_SUPERSET feature hook first. On a request whose DB
session is already broken -- exactly the state a SQLAlchemyError handler
runs in -- a valid guest token then yields the raw driver error instead of
a redacted message.

Make the fallback incapable of raising and fail closed for anyone
presenting a token:

- is_sanitization_required(): on failure, inside a request context fall
  back to whether the request carries a guest token (reading the header or
  form field cannot raise); redact if it does, leave genuinely anonymous
  errors untouched. Outside a request context (Celery worker under
  override_user) fail closed and redact, since the payload is delivered to
  the embedded viewer. Reworded the docstring as an availability-over-
  confidentiality trade-off rather than a definitional truth.

- Resolve the decision once and thread it down: add an optional
  `required` param to the sanitize_* helpers so a list is looked up a
  single time instead of once per error (fixes the N+1 loader calls and
  WARNING log-spam on multi-error responses).

- charts/data/api.py: replace the raw outer is_guest_user() with the
  guarded, once-resolved decision and reuse it for both the stacktrace pop
  and the message sanitization, so the block can no longer half-redact
  (drop the stacktrace while leaking the raw error).

- views/error_handling.py: give the last-resort show_unexpected_exception
  the same try/except FileNotFoundError fallback its siblings have, so a
  missing 500.html no longer collapses the response to a bare 500 with no
  SIP-40 body.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Elizabeth Thompson <eschutho@gmail.com>
request.form parses the body lazily on first access, so the guest-token
fallback in is_sanitization_required() could itself raise -- werkzeug's
RequestEntityTooLarge/ClientDisconnected for an oversized or truncated
body, or an OSError from the WSGI input stream or the multipart temp-file
spool -- escaping the error handler it exists to protect. Guard the form
read and fail closed when the body can't be inspected.

Also make test_send_chart_response_does_not_half_redact_guest_query_error
exercise the raising path it describes: the decision is resolved once, so
use a single raising side_effect and assert one lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Elizabeth Thompson <eschutho@gmail.com>
… read

The (HTTPException, OSError) catch around the fallback's request.form
read was too narrow: when the WSGI server terminates the input stream
(e.g. a chunked body under gunicorn), werkzeug reads the server's stream
directly, and gunicorn raises ParseException subclasses -- plain
Exception, not OSError -- for a malformed chunked trailer. That escaped
the error handler as a bare 500. Catch any exception and fail closed.

Tests: the unreadable-body case uses a token-free body so it only passes
by failing closed, and is parametrized with a server stream that raises
a plain Exception subclass to pin the broad catch. Correct the comments
on the partially-consumed-body case and on the chart-data sanitize block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Elizabeth Thompson <eschutho@gmail.com>
The last-resort show_unexpected_exception only caught FileNotFoundError
around send_file, so an unreadable 500.html (PermissionError,
NotADirectoryError, IsADirectoryError) still escaped the handler and
collapsed the response to a bare 500. Catch OSError. A sibling handler
whose send_file raises also lands here, so it degrades to a SIP-40 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Elizabeth Thompson <eschutho@gmail.com>
@eschutho
eschutho force-pushed the followup-error-sanitization-fail-open branch from 13c0983 to 6f36e71 Compare October 8, 2026 23:53
@eschutho

eschutho commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

@rusackas Rebased onto master again (no code changes) to pick up the new playwright-tests-gaq test infra and CI is fully green now. Would appreciate a re-look when you have a chance.

@rusackas rusackas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @eschutho, the form read now fails closed on any exception and the test only has one raising side_effect, so both of my points are covered. Leaving the shared helper alone makes sense given the auth path is allowed to raise and this one isn't. LGTM!

@rusackas
rusackas merged commit fb99e4b into master Oct 13, 2026
72 checks passed
@rusackas
rusackas deleted the followup-error-sanitization-fail-open branch October 13, 2026 16:20
villebro pushed a commit that referenced this pull request Oct 16, 2026
…principal (#43896)

Signed-off-by: Elizabeth Thompson <eschutho@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit fb99e4b)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Related to the REST API preset-io size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants