Skip to content

Differentiate authentication/request error messages (#274) - #3633

Open
wakqasahmed wants to merge 2 commits into
sunnah-com:masterfrom
wakqasahmed:fix/informative-error-messages-274
Open

Differentiate authentication/request error messages (#274)#3633
wakqasahmed wants to merge 2 commits into
sunnah-com:masterfrom
wakqasahmed:fix/informative-error-messages-274

Conversation

@wakqasahmed

Copy link
Copy Markdown

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:

  1. x-aws-secret gate (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 absent
    • 401 "Invalid 'x-aws-secret' header value." when it's present but wrong
  2. Pagination params (limit/page in paginate_results) — a non-integer ?limit= or ?page= raised an uncaught ValueError, producing an unformatted 500 instead of a JSON error. Now validated and aborted with 400 and a clear message naming the offending parameter and value.

  3. chapterId query param (/v1/hadiths) — a non-numeric ?chapterId= raised an uncaught ValueError (500) instead of a controlled 400. Now validated the same way as the existing urns/refs parsing already does elsewhere in the file.

Left out of scope (explicitly, since #274 is broader than a single fix):

  • Any error paths outside main.py (e.g. infra/gateway-level messages upstream of this Flask app).
  • 404s from 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.
  • A general error-handling refactor across the whole API.

Response shape is unchanged ({"error": {"details": ..., "code": ...}} via the existing jsonify_http_error handler) — only message content/differentiation changed.

Test plan

  • No existing test suite in the repo to extend.
  • Verified manually with a minimal standalone Flask app mirroring the exact modified code paths, exercising: missing auth header, wrong auth header, valid header, invalid limit, invalid page, invalid chapterId, valid chapterId — all returned the expected status code and message.
  • python3 -m py_compile main.py passes.

@wakqasahmed wakqasahmed left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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:

  1. float(chapter_id) still accepts nan / inf / -inf / Infinity / 1e400, which PyMySQL rejects at escape time -> uncaught ProgrammingError -> unformatted 500.
  2. limit / page are 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 in paginate()'s own bare abort(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.

Comment thread main.py
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.")

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Low / informational. Two small things about differentiating these two 401s:

  1. 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.

  2. 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_digest would be a one-line hardening if you want it, otherwise leave it for a separate change.

Comment thread main.py Outdated
try:
page = int(page_param)
except (TypeError, ValueError):
abort(400, f"Invalid 'page' query parameter: '{page_param}' is not an integer.")

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread main.py
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)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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 -> bare abort(404)
  • per_page < 0 -> bare abort(404)
  • not items and page != 1 -> bare abort(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=0 slips past per_page < 0 and yields LIMIT 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 enormous OFFSET, 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.

Comment thread main.py Outdated
try:
chapter_id = float(chapter_id)
except ValueError:
abort(400, f"Invalid 'chapterId' query parameter: '{chapter_id}' is not a number.")

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread main.py Outdated
try:
limit = int(limit_param)
except (TypeError, ValueError):
abort(400, f"Invalid 'limit' query parameter: '{limit_param}' is not an integer.")

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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
@wakqasahmed

Copy link
Copy Markdown
Author

Addressed the review feedback in 914af9c.

Blocking findings — fixed:

  1. chapterId non-finite values (nan, inf, -inf, Infinity, 1e400) now return a clean 400 ("... is not a finite number.") via math.isfinite(), instead of reaching PyMySQL's escape_float and raising an unformatted 500.
  2. limit/page are now range-checked (< 1 -> 400) before paginate() is called, so ?page=0, ?page=-1, ?limit=-1, ?limit=0 all get a clear 400 naming the actual bad parameter instead of Flask-SQLAlchemy's bare 404. (Out-of-range ?page= beyond the last page, e.g. ?page=999999, intentionally still returns 404 — kept as-is per the review's own "probably fine to leave" note, since that's "page doesn't exist" rather than "parameter is malformed".)

Non-blocking — fixed:
3. Added the missing \"400\" response entries to spec.v1.yml for the paginated endpoints (/collections, .../books, .../chapters, .../hadiths) and /hadiths (also covering the new chapterId 400). Mechanical addition only, matches the existing urns/refs doc style. scripts/validate_spec.py still passes (route-drift only, as noted). Not done: documenting the 401 from verify_secret — that's a global before_request guard, not per-operation, and would mean touching all 14 path operations for one repeated response; leaving that as a separate, explicit follow-up rather than doing it as a "small mechanical" change here.
4. Raw query-param values reflected into error messages/logs are now truncated to 50 chars via a small _truncate_param helper, so an oversized ?limit=<huge> can no longer inflate the response body/logs.

Nit — fixed:
5. chapterId's parse now catches (TypeError, ValueError) for consistency with the limit/page blocks (still practically unreachable there since request.args.get returns str/None, but matches style).

Not changed:

  • Very large ?page= (e.g. 10**19) reaching the DB as a huge OFFSET and potentially 500ing at the driver level — flagged in review as a related edge case, not one of the requested fixes. Didn't add an arbitrary upper bound since it risks rejecting legitimate deep pagination and wasn't part of the blocking asks; happy to add a sane cap in a follow-up if desired.
  • The two informational notes on verify_secret (header-name disclosure in the 401 body, non-constant-time secret comparison) — both explicitly called out as low-severity/pre-existing/adjacent, not required for this PR.

Verification: No formal test suite in this repo (confirmed again). Verified manually by building and running the stack via docker compose up against the real MySQL sample DB and hitting the live endpoints:

  • ?chapterId=nan|inf|-inf|Infinity|1e400 -> 400 with finite-number message (previously would 500)
  • ?chapterId=1.5 -> normal 200 (valid path unaffected)
  • ?page=0, ?page=-1, ?limit=-1, ?limit=0 -> 400 with clear message (previously 404 "Not Found")
  • ?limit=1&page=1 -> normal 200, unaffected
  • ?page=999999 -> still 404 (unchanged, intentional)
  • oversized junk ?limit= -> 400 with truncated (…50 chars) value in message
  • /v1/hadiths/urns, / -> unaffected, 200
  • python3 -c "import ast; ast.parse(...)" and python3 scripts/validate_spec.py both pass

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant