-
-
Notifications
You must be signed in to change notification settings - Fork 26
fix(web): say what actually went wrong instead of "unknown" #448
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
3ad8820
fix(web): say what actually went wrong instead of "unknown"
ChuckBuilds be8b053
fix(web): redact auth headers and URL userinfo, and cover every handler
ChuckBuilds e9a15ee
fix(web): stop reporting client errors as server faults
ChuckBuilds 6ae69df
fix(web): redact any auth scheme, and require the detail in the response
ChuckBuilds File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,248 @@ | ||
| """Tests for surfacing the underlying error in web responses. | ||
|
|
||
| Regression under test: every failing endpoint returned "An error occurred; see | ||
| logs for details" and nothing else. On a device whose storage was failing that | ||
| sentence came back from the restart action, from /system/status, and from | ||
| /logs -- the log viewer itself -- because journalctl could not be executed. The | ||
| exception underneath said `[Errno 5] Input/output error: 'systemctl'`, which | ||
| names the fault outright, and nine handlers were discarding it entirely rather | ||
| than even logging it. | ||
| """ | ||
|
|
||
| import pytest | ||
|
|
||
| from src.web_interface.error_handler import describe_exception | ||
|
|
||
|
|
||
| class TestDescribeException: | ||
| def test_names_the_type_and_message(self): | ||
| detail = describe_exception(OSError(5, "Input/output error", "systemctl")) | ||
| assert detail == "OSError: [Errno 5] Input/output error: 'systemctl'" | ||
|
|
||
| def test_the_reported_failure_is_legible(self): | ||
| # The whole point: this string is the diagnosis. | ||
| assert "Input/output error" in describe_exception( | ||
| OSError(5, "Input/output error", "systemctl")) | ||
|
|
||
| def test_a_bare_exception_still_names_its_type(self): | ||
| # A PermissionError with no message still says more than "unknown". | ||
| assert describe_exception(PermissionError()) == "PermissionError" | ||
| assert describe_exception(Exception()) == "Exception" | ||
|
|
||
| def test_message_is_kept_when_present(self): | ||
| assert describe_exception(ValueError("bad port")) == "ValueError: bad port" | ||
|
|
||
|
|
||
| class TestCredentialRedaction: | ||
| """Exception text quotes URLs, and plugins authenticate by query string.""" | ||
|
|
||
| @pytest.mark.parametrize("secret_text,leaked", [ | ||
| ("failed: https://api.x.com/v1?api_key=SEC123&city=Tampa", "SEC123"), | ||
| ("token=abcdef123456 was rejected", "abcdef123456"), | ||
| ("connect failed password=hunter2", "hunter2"), | ||
| ("GET /?access_token=zzz999", "zzz999"), | ||
| ('{"secret": "topsecret"}', "topsecret"), | ||
| # requests quotes the URL it failed on, and both of these forms turn | ||
| # up in real client exceptions. | ||
| ("401 for https://user:hunter2@example.com/api", "hunter2"), | ||
| ("headers: {'Authorization': 'Bearer eyJ.SECRET.sig'}", "eyJ.SECRET.sig"), | ||
| ("Authorization: Basic dXNlcjpwYXNzd29yZA==", "dXNlcjpwYXNzd29yZA=="), | ||
| ("Proxy-Authorization: Bearer ptok999", "ptok999"), | ||
| # Any scheme, not a fixed list -- a list silently leaks whatever it | ||
| # does not name, and plugin APIs invent their own. | ||
| ("Authorization: ApiKey SECRET123", "SECRET123"), | ||
| ("Authorization: Negotiate YIIZnegotiateblob", "YIIZnegotiateblob"), | ||
| ("Authorization: NTLM TlRMTVNTUAAB", "TlRMTVNTUAAB"), | ||
| ("authorization: barecredential", "barecredential"), | ||
| ]) | ||
| def test_credentials_never_reach_the_response(self, secret_text, leaked): | ||
| detail = describe_exception(RuntimeError(secret_text)) | ||
| assert leaked not in detail | ||
| assert "<redacted>" in detail | ||
|
|
||
| def test_the_parameter_name_survives_redaction(self): | ||
| # Knowing *which* credential was involved is part of the diagnosis. | ||
| detail = describe_exception(RuntimeError("https://x/y?api_key=SEC123")) | ||
| assert "api_key" in detail | ||
|
|
||
| def test_unknown_schemes_keep_their_name(self): | ||
| for scheme in ("ApiKey", "Negotiate", "NTLM", "AWS4-HMAC-SHA256"): | ||
| detail = describe_exception( | ||
| RuntimeError("Authorization: %s SECRETVALUE" % scheme)) | ||
| assert scheme in detail, detail | ||
| assert "SECRETVALUE" not in detail, detail | ||
|
|
||
| def test_auth_scheme_and_username_survive(self): | ||
| # Which kind of credential, and whose, without the credential itself. | ||
| assert "Bearer" in describe_exception( | ||
| RuntimeError("Authorization: Bearer eyJ.SECRET.sig")) | ||
| assert "user" in describe_exception( | ||
| RuntimeError("https://user:hunter2@example.com")) | ||
|
|
||
| def test_non_secret_context_is_preserved(self): | ||
| detail = describe_exception(RuntimeError("https://api.x.com/v1?city=Tampa")) | ||
| assert "city=Tampa" in detail | ||
| assert "<redacted>" not in detail | ||
|
|
||
|
|
||
| class TestBounds: | ||
| def test_long_messages_are_truncated(self): | ||
| detail = describe_exception(ValueError("x" * 5000)) | ||
| assert len(detail) <= 400 | ||
|
|
||
| def test_newlines_are_collapsed_to_one_line(self): | ||
| detail = describe_exception(ValueError("line one\nline two\tthree")) | ||
| assert "\n" not in detail and "\t" not in detail | ||
| assert detail == "ValueError: line one line two three" | ||
|
|
||
| def test_custom_length_is_honoured(self): | ||
| assert len(describe_exception(ValueError("y" * 500), max_length=50)) <= 50 | ||
|
|
||
|
|
||
| class TestHandlersCarryDetail: | ||
| """The response shape callers actually see.""" | ||
|
|
||
| def test_no_api_v3_handler_discards_its_exception(self): | ||
| """Every generic-message handler must log a traceback and return detail. | ||
|
|
||
| Nine of them bound `e` and never used it, so the promised log entry was | ||
| never written either. Checking merely that *something* was logged is | ||
| too weak -- a `logger.info("failed")` would satisfy it while throwing | ||
| the exception away just as completely, so this asserts the two things | ||
| that actually make the failure diagnosable: an error-level record with | ||
| the traceback, and the sanitized detail in the response. | ||
| """ | ||
| import ast | ||
|
|
||
| src = open("web_interface/blueprints/api_v3.py").read() | ||
| tree = ast.parse(src) | ||
| generic = "An error occurred; see logs for details" | ||
|
|
||
| def logs_a_traceback(handler): | ||
| """An error/exception-level log call carrying exc_info.""" | ||
| for call in [n for n in ast.walk(handler) if isinstance(n, ast.Call)]: | ||
| func = call.func | ||
| if not isinstance(func, ast.Attribute): | ||
| continue | ||
| if func.attr == "exception": # implies exc_info | ||
| return True | ||
| if func.attr not in ("error", "critical"): | ||
| continue | ||
| if any(kw.arg == "exc_info" and getattr(kw.value, "value", False) is True | ||
| for kw in call.keywords): | ||
| return True | ||
| return False | ||
|
|
||
| def describes_this_exception(node, bound): | ||
| """A describe_exception(<bound>) call anywhere under `node`.""" | ||
| for call in [n for n in ast.walk(node) if isinstance(n, ast.Call)]: | ||
| if not (isinstance(call.func, ast.Name) | ||
| and call.func.id == "describe_exception"): | ||
| continue | ||
| if bound is None: | ||
| return True # bare `except:` cannot name it; accept | ||
| if any(isinstance(a, ast.Name) and a.id == bound | ||
| for a in call.args): | ||
| return True | ||
| return False | ||
|
|
||
| def returns_the_detail(handler): | ||
| """The detail must be inside what the handler actually returns. | ||
|
|
||
| Looking anywhere in the handler is too weak: a handler could | ||
| compute describe_exception(e), drop it on the floor, and return the | ||
| generic message with no details field, while still passing. So the | ||
| call has to appear within a `return` expression. | ||
| """ | ||
| returns = [n for n in ast.walk(handler) if isinstance(n, ast.Return)] | ||
| if not returns: | ||
| return False | ||
| return all(describes_this_exception(r, handler.name) for r in returns) | ||
|
|
||
| offenders = [] | ||
| for h in [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)]: | ||
| seg = ast.get_source_segment(src, h) or "" | ||
| if generic not in seg: | ||
| continue | ||
| missing = [] | ||
| if not logs_a_traceback(h): | ||
| missing.append("error-level log with exc_info") | ||
| if not returns_the_detail(h): | ||
| missing.append("describe_exception(e) in the response") | ||
| if missing: | ||
| offenders.append((h.lineno, missing)) | ||
|
|
||
| assert not offenders, ( | ||
| "handlers returning the generic message without %s: %r" | ||
| % ("both a traceback log and the detail", offenders)) | ||
|
|
||
| def test_client_errors_keep_their_own_status(self): | ||
| """A 405 must not be reported as a server-side UNKNOWN_ERROR. | ||
|
|
||
| Werkzeug's HTTPExceptions subclass Exception, so the catch-all saw them | ||
| too: a GET on a POST-only route came back 500 "an error occurred", | ||
| which tells the caller nothing and blames the wrong side. Found while | ||
| probing a device whose POST-only config endpoints answered every GET | ||
| with UNKNOWN_ERROR. | ||
| """ | ||
| from flask import Flask, jsonify | ||
| from werkzeug.exceptions import HTTPException | ||
|
|
||
| app = Flask(__name__) | ||
|
|
||
| @app.errorhandler(Exception) | ||
| def handle(error): | ||
| if isinstance(error, HTTPException): | ||
| return jsonify({ | ||
| "status": "error", | ||
| "error_code": (error.name or "HTTP_ERROR").upper().replace(" ", "_"), | ||
| "message": error.description, | ||
| }), error.code or 500 | ||
| return jsonify({ | ||
| "status": "error", | ||
| "error_code": "UNKNOWN_ERROR", | ||
| "message": "An error occurred; see logs for details", | ||
| "details": describe_exception(error), | ||
| }), 500 | ||
|
|
||
| @app.route("/only-post", methods=["POST"]) | ||
| def only_post(): | ||
| return jsonify({"ok": True}) | ||
|
|
||
| @app.route("/boom") | ||
| def boom(): | ||
| raise OSError(5, "Input/output error", "systemctl") | ||
|
|
||
| client = app.test_client() | ||
|
|
||
| resp = client.get("/only-post") | ||
| assert resp.status_code == 405, "a wrong method must stay a 405" | ||
| assert resp.get_json()["error_code"] == "METHOD_NOT_ALLOWED" | ||
|
|
||
| # A genuine server fault still reports as one, with its detail. | ||
| resp = client.get("/boom") | ||
| assert resp.status_code == 500 | ||
| assert "Input/output error" in resp.get_json()["details"] | ||
|
|
||
| def test_global_handler_reports_the_underlying_error(self): | ||
| from flask import Flask, jsonify | ||
|
|
||
| app = Flask(__name__) | ||
|
|
||
| @app.errorhandler(Exception) | ||
| def handle(error): | ||
| return jsonify({ | ||
| "status": "error", | ||
| "error_code": "UNKNOWN_ERROR", | ||
| "message": "An error occurred; see logs for details", | ||
| "details": describe_exception(error), | ||
| }), 500 | ||
|
|
||
| @app.route("/boom") | ||
| def boom(): | ||
| raise OSError(5, "Input/output error", "systemctl") | ||
|
|
||
| client = app.test_client() | ||
| body = client.get("/boom").get_json() | ||
| assert body["error_code"] == "UNKNOWN_ERROR" | ||
| assert "Input/output error" in body["details"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.