Answer a nonsense filter with a 400, and sweep every list route for the rest

Wave 3. Six findings, one cause: `int(request.args.get('typeid'))` with nothing
catching the ValueError. The caller sent a bad argument and got
500 INTERNAL_ERROR plus a traceback in the log, which says the server is broken
when the truth is that 'abc' is not a number.

shopdb/utils/queryargs.intarg reads it and raises QueryArgError, which an
app-level handler answers as a 400 naming the argument. App-level on purpose: a
route should not have to remember, and forgetting was the entire defect. The
policy differs from get_pagination_params, which falls back to a default for a
bad page number - a nonsense page is harmless and the caller still wants their
list, but silently ignoring a nonsense FILTER returns rows they believe they
excluded.

The gate matters more than the six fixes. tests/test_core/test_bad_query_args.py
sweeps all 131 registered no-parameter GET routes under /api/ with twelve
argument shapes and fails on any 5xx. It found sites the test run had not
reported - statusid and locationid on /api/assets, the second asset-list route,
limit on the employee lookup and the printer supply history - because the
pattern is how a list filter gets written rather than a property of any one
route. Verified non-vacuous by planting the old `int(...)` back into
/api/machines: four tests fail.

Four usb routes are skipped by exact path, with the reason: they reach the
EXTERNAL cmmc_usb database, so their 500 in a test environment is a missing
database rather than an argument fault. That is the complete set - every usb
GET route calling cmmc_usb_connection - not a convenient subset.

One correction worth recording: the first pass rewrote every
`if x := request.args.get(...)` in the touched files, including text arguments,
so a search for 'ZZNOTHINGMATCHES' briefly became a 400. Caught by
test_list_search_identifiers. Only id-shaped arguments are integers.

Scope: product
This commit is contained in:
cproudlock
2026-09-03 14:18:49 -04:00
parent 12392a361e
commit debef39a18
13 changed files with 285 additions and 84 deletions

View File

@@ -400,6 +400,16 @@ def register_error_handlers(app: Flask):
% (' for %s' % column if column else ''),
http_code=400)
# A wrong query argument is the caller's mistake. Registered app-wide so a
# list route does not have to remember to catch it - forgetting was the
# whole defect, in six routes across five plugins.
from .utils.queryargs import QueryArgError
@app.errorhandler(QueryArgError)
def handle_query_arg_error(error):
return error_response(ErrorCodes.VALIDATION_ERROR, str(error),
http_code=400)
@app.errorhandler(ShopDBException)
def handle_shopdb_exception(error):
http_codes = {

View File

@@ -112,6 +112,8 @@ from shopdb.utils.clientip import client_ip, strip_port
# Strict date parsing. On the contract surface because every plugin that stores
# a date needs it and the two that rolled their own both swallowed the error.
from shopdb.utils.queryargs import QueryArgError, intarg, intargs
from shopdb.utils.datevalues import (
DateValueError,
parse_date,
@@ -359,6 +361,9 @@ __all__ = [
'success_response',
'error_response',
'DateValueError',
'QueryArgError',
'intarg',
'intargs',
'parse_date',
'parse_date_fields',
'paginated_response',

View File

@@ -13,6 +13,7 @@ from shopdb.utils.responses import (
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.queryargs import intarg
from shopdb.utils.authz import require_permission
from shopdb.utils.import_mode import apply_import_timestamps
@@ -365,20 +366,20 @@ def list_assets():
query = query.join(AssetType).filter(AssetType.assettype == type_name)
# Type filter by ID
if type_id := request.args.get('typeid', request.args.get('type_id')):
query = query.filter(Asset.assettypeid == int(type_id))
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
query = query.filter(Asset.assettypeid == type_id)
# Status filter
if status_id := request.args.get('statusid', request.args.get('status_id')):
query = query.filter(Asset.statusid == int(status_id))
if (status_id := intarg('statusid', intarg('status_id'))) is not None:
query = query.filter(Asset.statusid == status_id)
# Location filter
if location_id := request.args.get('locationid', request.args.get('location_id')):
query = query.filter(Asset.locationid == int(location_id))
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
query = query.filter(Asset.locationid == location_id)
# Business unit filter
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
query = query.filter(Asset.businessunitid == int(bu_id))
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
query = query.filter(Asset.businessunitid == bu_id)
# Sorting
sort_by = request.args.get('sort', 'assetnumber')
@@ -1030,8 +1031,7 @@ def get_assets_map():
query = query.join(AssetType).filter(AssetType.assettype == selected_assettype)
# Filter by subtype (depends on asset type) - case-insensitive matching
if subtype_id := request.args.get('subtype'):
subtype_id = int(subtype_id)
if (subtype_id := intarg('subtype')) is not None:
# Normalize the underscore DB form (measuring_tool, network_device) to
# the space form the branches below compare against.
asset_type_lower = (
@@ -1079,16 +1079,16 @@ def get_assets_map():
pass
# Filter by business unit
if bu_id := request.args.get('businessunitid'):
query = query.filter(Asset.businessunitid == int(bu_id))
if (bu_id := intarg('businessunitid')) is not None:
query = query.filter(Asset.businessunitid == bu_id)
# Filter by status
if status_id := request.args.get('statusid'):
query = query.filter(Asset.statusid == int(status_id))
if (status_id := intarg('statusid')) is not None:
query = query.filter(Asset.statusid == status_id)
# Filter by location
if location_id := request.args.get('locationid'):
query = query.filter(Asset.locationid == int(location_id))
if (location_id := intarg('locationid')) is not None:
query = query.filter(Asset.locationid == location_id)
# Search filter
if search := request.args.get('search'):

View File

@@ -13,6 +13,7 @@ from shopdb.core.models import (
)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_permission
from shopdb.utils.queryargs import intarg
reports_bp = Blueprint('reports', __name__)
@@ -62,8 +63,8 @@ def machines_by_type():
)
# Business unit filter
if bu_id := request.args.get('businessunitid'):
query = query.filter(Asset.businessunitid == int(bu_id))
if (bu_id := intarg('businessunitid')) is not None:
query = query.filter(Asset.businessunitid == bu_id)
# Dualpath single-machine collapse: exclude the secondary bay so a dual-bay
# pair counts once per type bucket. Gated on the site setting (default on).
@@ -128,12 +129,12 @@ def assets_by_status():
)
# Asset type filter
if type_id := request.args.get('assettypeid'):
query = query.filter(Asset.assettypeid == int(type_id))
if (type_id := intarg('assettypeid')) is not None:
query = query.filter(Asset.assettypeid == type_id)
# Business unit filter
if bu_id := request.args.get('businessunitid'):
query = query.filter(Asset.businessunitid == int(bu_id))
if (bu_id := intarg('businessunitid')) is not None:
query = query.filter(Asset.businessunitid == bu_id)
query = query.group_by(
AssetStatus.statusid,
@@ -175,7 +176,7 @@ def kb_popularity():
- limit: Number of results (default 20)
- format: 'json' (default) or 'csv'
"""
limit = min(int(request.args.get('limit', 20)), 100)
limit = min(intarg('limit', 20), 100)
# Knowledge Base is a plugin; degrade gracefully if it is not installed.
try:
@@ -362,10 +363,10 @@ def asset_inventory():
AssetType.isactive == True
)
if bu_id := request.args.get('businessunitid'):
by_type = by_type.filter(Asset.businessunitid == int(bu_id))
if loc_id := request.args.get('locationid'):
by_type = by_type.filter(Asset.locationid == int(loc_id))
if (bu_id := intarg('businessunitid')) is not None:
by_type = by_type.filter(Asset.businessunitid == bu_id)
if (loc_id := intarg('locationid')) is not None:
by_type = by_type.filter(Asset.locationid == loc_id)
by_type = by_type.group_by(AssetType.assettype).all()
@@ -380,10 +381,10 @@ def asset_inventory():
AssetStatus.isactive == True
)
if bu_id := request.args.get('businessunitid'):
by_status = by_status.filter(Asset.businessunitid == int(bu_id))
if loc_id := request.args.get('locationid'):
by_status = by_status.filter(Asset.locationid == int(loc_id))
if (bu_id := intarg('businessunitid')) is not None:
by_status = by_status.filter(Asset.businessunitid == bu_id)
if (loc_id := intarg('locationid')) is not None:
by_status = by_status.filter(Asset.locationid == loc_id)
by_status = by_status.group_by(AssetStatus.status, AssetStatus.color).all()
@@ -398,8 +399,8 @@ def asset_inventory():
Location.isactive == True
)
if bu_id := request.args.get('businessunitid'):
by_location = by_location.filter(Asset.businessunitid == int(bu_id))
if (bu_id := intarg('businessunitid')) is not None:
by_location = by_location.filter(Asset.businessunitid == bu_id)
by_location = by_location.group_by(Location.locationname).all()

64
shopdb/utils/queryargs.py Normal file
View File

@@ -0,0 +1,64 @@
"""Reading a typed value out of the query string, without a 500.
WHY THIS EXISTS
List endpoints filtered like this:
if type_id := request.args.get('typeid'):
query = query.filter(Machine.machinetypeid == int(type_id))
`int('abc')` raises, nothing catches it, and the caller gets
500 INTERNAL_ERROR with a traceback in the log. A wrong query argument is the
caller's mistake and deserves a 400 saying which argument; a 500 says the server
is broken and sends whoever is reading the log looking for a fault that is not
there. The feature test run found the same shape in six places across five
plugins, which makes it a property of how list routes are written rather than of
any one route.
The helpers raise QueryArgError. Routes turn it into a 400 - or, more usually,
nothing does anything at all, because the app-level handler in
shopdb/__init__.py answers it. That is the point: a route should not have to
remember.
Deliberately NOT the same policy as get_pagination_params, which falls back to
a default for a bad page number. A nonsense page is harmless and the caller
still wants their list; a nonsense FILTER is not, because silently ignoring it
returns rows the caller believes they excluded.
"""
from flask import request
class QueryArgError(ValueError):
"""A query argument was present but not of the type the route needs."""
def __init__(self, name, value, expected='a whole number'):
self.name = name
self.value = value
super().__init__('%s must be %s (got %r)' % (name, expected, value))
def intarg(name, default=None, request_obj=None):
"""Read an integer query argument.
Absent or empty -> default. Present but not an integer -> QueryArgError,
which the app answers as a 400 naming the argument.
"""
source = request_obj if request_obj is not None else request
raw = source.args.get(name)
if raw is None or raw == '':
return default
try:
return int(raw)
except (TypeError, ValueError):
raise QueryArgError(name, raw)
def intargs(*names, request_obj=None):
"""Read several integer arguments at once; returns a dict of those present."""
found = {}
for name in names:
value = intarg(name, request_obj=request_obj)
if value is not None:
found[name] = value
return found