Differentiate authentication/request error messages (#274) - #3633
Differentiate authentication/request error messages (#274)#3633wakqasahmed wants to merge 2 commits into
Conversation
wakqasahmed
left a comment
There was a problem hiding this comment.
Cold-start review of the diff against #274.
What holds up: the verify_secret refactor is semantically equivalent to the old if not app.debug and ... guard (no regression on the debug path or the happy path); the correct-input paths through paginate_results and chapterId are untouched; and the response shape stays {"error": {"details": ..., "code": ...}} via the existing jsonify_http_error handler, so no contract change for clients. abort(400, "...") matches the style already used by api_hadiths_by_urns / api_hadiths_by_refs. Defaults are ints (request.args.get("limit", 50)), so int(50) is a no-op and the TypeError arm is dead but harmless.
Two substantive gaps (inline), both of which leave the exact failure mode #274 describes in place on lines this PR touched:
float(chapter_id)still acceptsnan/inf/-inf/Infinity/1e400, which PyMySQL rejects at escape time -> uncaughtProgrammingError-> unformatted 500.limit/pageare type-checked but not range-checked. With Flask-SQLAlchemy 2.5.1,?page=0,?page=-1,?limit=-1, and any out-of-range?page=still bottom out inpaginate()'s own bareabort(404), i.e. a"Not Found"with no explanation of which parameter was wrong.
Plus a doc-drift note and two low-severity hardening notes inline. No blocking security issue found: the reflected values go out as application/json via jsonify, so there is no practical XSS vector, and the auth comparison being non-constant-time is pre-existing, not introduced here.
| if secret is None: | ||
| abort(401, "Missing 'x-aws-secret' header.") | ||
| if secret != app.config["AWS_SECRET"]: | ||
| abort(401, "Invalid 'x-aws-secret' header value.") |
There was a problem hiding this comment.
Low / informational. Two small things about differentiating these two 401s:
-
The response body now names the internal auth header (
x-aws-secret) to unauthenticated callers, which the previous generic Werkzeug message did not. That is a small disclosure of the internal gateway mechanism. If the intent is to help legitimate integrators, naming the header in the "missing" case is defensible; echoing it again in the "invalid value" case adds nothing they do not already know and gives a scanner a positive signal that the header is the right one. Consider collapsing the second message to something that does not confirm header correctness, or keep both and accept the (small) tradeoff deliberately. -
Unrelated to this PR but adjacent to the line you are editing:
secret != app.config["AWS_SECRET"]is a non-constant-time comparison. Pre-existing, not introduced here — flagging only because you are already in this block;hmac.compare_digestwould be a one-line hardening if you want it, otherwise leave it for a separate change.
| try: | ||
| page = int(page_param) | ||
| except (TypeError, ValueError): | ||
| abort(400, f"Invalid 'page' query parameter: '{page_param}' is not an integer.") |
There was a problem hiding this comment.
Low. Both messages reflect the raw parameter value back with no length bound. ?limit=<100KB of junk> produces a ~100KB JSON error body, and it lands in access/error logs at the same size. Truncating to something like limit_param[:50] in the message would keep the message just as informative without giving a caller control over response size.
| except (TypeError, ValueError): | ||
| abort(400, f"Invalid 'page' query parameter: '{page_param}' is not an integer.") | ||
|
|
||
| queryset = f(*args, **kwargs).paginate(page=page, per_page=limit, max_per_page=100) |
There was a problem hiding this comment.
Main finding: range is still unvalidated, so the misleading-error case from #274 survives on this exact code path.
The try/except int(...) above only rules out non-integers. Flask-SQLAlchemy 2.5.1 BaseQuery.paginate() (pinned in requirements.txt) then does its own validation with error_out=True defaulted on:
page < 1-> bareabort(404)per_page < 0-> bareabort(404)not items and page != 1-> bareabort(404)
So after this PR:
?page=0->{"error": {"details": "Not Found", "code": 404}}?page=-1-> same?limit=-1-> same?page=999999(past the last page) -> same
Those are exactly the "error message does not tell you what is actually wrong" reports #274 is about, and arguably more common in practice than passing a non-integer. A caller who typos ?page=0 gets a 404 that reads as "this collection does not exist".
Suggested minimum: after the two int() conversions, add
if limit < 1:
abort(400, f"Invalid 'limit' query parameter: must be >= 1 (max 100).")
if page < 1:
abort(400, f"Invalid 'page' query parameter: must be >= 1.")and consider whether an out-of-range page should stay a 404 or become a 400 with a message naming the last valid page (error_out=False + an explicit check gives you that).
Two related edge cases while you are here:
?limit=0slips pastper_page < 0and yieldsLIMIT 0-> a 200 with"data": []and"limit": 0, which is silently wrong rather than an error.- A very large
?page=(e.g.10**19) is a valid Python int and reaches the DB as an enormousOFFSET, which MySQL can reject outright -> another unformatted 500. An upper bound, or catching the driver error, closes that.
Note also that ?limit=1000 is silently clamped by max_per_page=100 rather than reported — consistent with the current spec (maximum: 100) so probably fine to leave, but worth a conscious decision given the PR's theme.
| try: | ||
| chapter_id = float(chapter_id) | ||
| except ValueError: | ||
| abort(400, f"Invalid 'chapterId' query parameter: '{chapter_id}' is not a number.") |
There was a problem hiding this comment.
Main finding: float() is too permissive here, so this still 500s on a class of bad input.
float() accepts more than "a number":
float('nan') -> nan
float('inf') -> inf
float('-inf') -> -inf
float('Infinity') -> inf
float('1e400') -> inf # silent overflow, no ValueError
None of those raise ValueError, so they sail past this except and go straight into filter_by(babID=chapter_id). PyMySQL's escape_float explicitly refuses them:
if s in ("inf", "-inf", "nan"):
raise ProgrammingError("%s can not be used with MySQL" % s)ProgrammingError is not an HTTPException, so jsonify_http_error does not catch it and ?chapterId=nan / ?chapterId=1e400 returns an unformatted 500 — the precise failure this hunk sets out to fix. Fix is one line:
try:
chapter_id = float(chapter_id)
except (TypeError, ValueError):
abort(400, f"Invalid 'chapterId' query parameter: '{chapter_id}' is not a number.")
if not math.isfinite(chapter_id):
abort(400, f"Invalid 'chapterId' query parameter: '{chapter_id}' is not a finite number.")(import math at the top.) Worth folding the isfinite check into the same abort message if you would rather not have two.
One thing that is correct and easy to misread: the except block's f-string uses chapter_id, and because the assignment on the preceding line failed, chapter_id is still the original string there — so the message reports what the user actually sent. Good.
Nit: this catches ValueError only, while the limit/page blocks catch (TypeError, ValueError). Neither can actually see a TypeError here, but the inconsistency will read as accidental to the next person.
| try: | ||
| limit = int(limit_param) | ||
| except (TypeError, ValueError): | ||
| abort(400, f"Invalid 'limit' query parameter: '{limit_param}' is not an integer.") |
There was a problem hiding this comment.
Doc drift — worth catching before merge. This PR introduces new 400 responses on every @paginate_results endpoint and on /v1/hadiths, but spec.v1.yml only documents 400 for /hadiths/urns and /hadiths/refs. The 401 from verify_secret is undocumented too. Since the repo clearly treats the spec as a maintained contract (there is a Spec drift check workflow, and the existing urns/refs 400s are documented), adding the "400": description: ... entries for the paginated endpoints and /hadiths would keep it consistent.
Note that scripts/validate_spec.py validates route drift, not response-code drift, so CI will stay green on this — it needs to be done by hand.
…com#274) - reject nan/inf/-inf/Infinity/1e400 chapterId values with a clear 400 instead of letting them reach PyMySQL and raise an unformatted 500 - reject limit/page < 1 with a clear 400 instead of falling through to Flask-SQLAlchemy's bare 404 from paginate(error_out=True) - truncate raw query-param values echoed into error messages to 50 chars - document the new 400 responses for the paginated endpoints and /hadiths in spec.v1.yml
|
Addressed the review feedback in 914af9c. Blocking findings — fixed:
Non-blocking — fixed: Nit — fixed: Not changed:
Verification: No formal test suite in this repo (confirmed again). Verified manually by building and running the stack via
|
Addresses #274 — scoped down from the full ask. The issue notes any error can collapse to a generic/misleading message; a full sweep of every error path in the API would be out of scope for one PR, so this fixes the 3 highest-value, most misleading cases:
x-aws-secretgate (verify_secret) —abort(401)with no description returned Werkzeug's generic default message ("could not verify... wrong credentials...") for both a missing header and a wrong header value. Now returns:401 "Missing 'x-aws-secret' header."when the header is absent401 "Invalid 'x-aws-secret' header value."when it's present but wrongPagination params (
limit/pageinpaginate_results) — a non-integer?limit=or?page=raised an uncaughtValueError, producing an unformatted 500 instead of a JSON error. Now validated and aborted with400and a clear message naming the offending parameter and value.chapterIdquery param (/v1/hadiths) — a non-numeric?chapterId=raised an uncaughtValueError(500) instead of a controlled400. Now validated the same way as the existingurns/refsparsing already does elsewhere in the file.Left out of scope (explicitly, since #274 is broader than a single fix):
main.py(e.g. infra/gateway-level messages upstream of this Flask app).first_or_404()on collection/book/chapter/hadith lookups — these already report the correct status and aren't misleading, just generic; changing their wording would be a broader UX pass, not a bug fix.Response shape is unchanged (
{"error": {"details": ..., "code": ...}}via the existingjsonify_http_errorhandler) — only message content/differentiation changed.Test plan
limit, invalidpage, invalidchapterId, validchapterId— all returned the expected status code and message.python3 -m py_compile main.pypasses.