Refuse a value the database cannot store, instead of quietly changing it

Wave 2 of the remediation plan. Eight findings from the feature test run were
the same defect twice: a write that reports success while storing something
other than what was sent.

The first half is a date parser that swallowed its own error. Two plugins
carried an identical private _parse_date returning None on ValueError, so an
unparseable date and a deliberately cleared one produced the same write.
PUT /api/warranty/<id> with enddate '30-06-2027' answered 200 "Warranty
updated" and stored NULL, dropping the record out of every expiry surface on
the strength of a typo; the measuring-tool calibration date did the same and
flipped the tool to 'unknown', out of the calibration report and the map
overlay. shopdb/utils/datevalues.py now holds one strict parser on the contract
surface: an explicitly empty value clears the field, anything that is not a
date is refused with a 400 naming the field, and those are different intents
with different answers. The vendor-lookup paths keep a lenient wrapper on
purpose - a provider changing its date shape must not fail the refresh of an
otherwise good record, and no person typed that value.

The second half is why the truncation findings were possible at all. MySQL was
running non-strict: sql_mode on the 5.6 server is NO_ENGINE_SUBSTITUTION with
no STRICT_TRANS_TABLES, so it accepted writes it could not store and adjusted
them. A 257-character username became its first 100 characters (and the next
distinct one then collided with it); an over-long asset number was cut at 50; a
malformed releasedate became '0000-00-00' and permanently broke every read of
that application. All with a 201. The mode is now set on the CONNECTION rather
than left to the server, so a site whose my.cnf disagrees still gets it.

That trades silent corruption for a DataError, which uncaught is a 500 - and a
500 says the server is broken when the truth is that the value does not fit. An
error handler maps it to a 400 naming the column where MySQL names one.
Verified by hand against MySQL 5.6: POST /api/users with a 257-character
username now answers 400 "That value is too long for username." and stores
nothing, where it previously stored a truncated row and answered 201.

Twelve tests. The date guard is verified by planting: restore the swallowing
parser and four of them fail. The DataError tests raise the error directly
because the suite runs on SQLite, which does not enforce VARCHAR length at all -
there is no value that would trigger it there, which is precisely why this went
unnoticed.

Scope: product
This commit is contained in:
cproudlock
2026-09-03 13:58:41 -04:00
parent aa8028f96e
commit 12392a361e
9 changed files with 419 additions and 34 deletions

View File

@@ -368,6 +368,38 @@ def register_error_handlers(app: Flask):
return error_response(ErrorCodes.VALIDATION_ERROR,
'The database rejected that change.', http_code=400)
# STRICT_TRANS_TABLES (see config.SQLALCHEMY_ENGINE_OPTIONS) turns what
# MySQL used to silently adjust into an error: a value too long for its
# column, a date that is not a date, a number that is not a number. That is
# the behaviour we want - the alternative is a 201 for a row the caller did
# not send - but the error is the CALLER's, so it must not read as a server
# fault. Without this it surfaced as a 500 with a traceback in the log.
from sqlalchemy.exc import DataError
@app.errorhandler(DataError)
def handle_data_error(error):
from .extensions import db
db.session.rollback()
message = str(getattr(error, 'orig', error))
app.logger.warning('data error: %s', message)
column = _column_from_data_error(message)
if 'too long' in message.lower():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'That value is too long for %s.' % (column or 'this field'),
http_code=400)
if 'incorrect date' in message.lower() or \
'incorrect datetime' in message.lower():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'That is not a valid date for %s.' % (column or 'this field'),
http_code=400)
return error_response(
ErrorCodes.VALIDATION_ERROR,
'The database rejected that value%s.'
% (' for %s' % column if column else ''),
http_code=400)
@app.errorhandler(ShopDBException)
def handle_shopdb_exception(error):
http_codes = {
@@ -410,6 +442,20 @@ def register_error_handlers(app: Flask):
)
def _column_from_data_error(message):
"""Pull the column name out of a MySQL data error, when it names one.
MySQL says "Data too long for column 'username' at row 1"; SQLite says
nothing of the sort. Best effort on purpose - a message that names the
field is much more useful than a generic one, and a message that names
nothing is still better than a 500.
"""
import re
found = re.search(r"column '([^']+)'", message or '')
return found.group(1) if found else None
def register_frontend_routes(app: Flask):
"""Serve Vue frontend static files."""
frontend_dist = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'frontend', 'dist')

View File

@@ -110,6 +110,14 @@ from shopdb.utils.uploads import send_upload, harden_upload_response
# nothing. See shopdb/utils/clientip.py.
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.datevalues import (
DateValueError,
parse_date,
parse_date_fields,
)
# Import-mode helpers: preserve legacy timestamps during a bulk data import
from shopdb.utils.import_mode import (
apply_import_timestamps,
@@ -350,6 +358,9 @@ __all__ = [
# Response + pagination helpers
'success_response',
'error_response',
'DateValueError',
'parse_date',
'parse_date_fields',
'paginated_response',
'ErrorCodes',
'get_pagination_params',

View File

@@ -38,7 +38,26 @@ class Config:
# Pin the client connection to utf8mb4 so reads/writes match the
# utf8mb4 schema regardless of the server default charset. TestingConfig
# (SQLite) replaces this whole dict, so the charset arg never reaches it.
'connect_args': {'charset': 'utf8mb4'},
#
# STRICT_TRANS_TABLES is the other half, and it is a correctness
# setting rather than a preference. Without it MySQL accepts a write it
# cannot store and adjusts it: a 257-character username becomes the
# first 100 characters, a malformed date becomes '0000-00-00', an
# over-long asset number is cut at 50 and then collides with a
# different one. Every one of those was found in the product answering
# 200 or 201, because nothing in the stack knew the value had changed.
#
# Set on the CONNECTION rather than left to the server, so a site whose
# my.cnf says otherwise still gets it - the sql_mode seen on the 5.6
# dev server was NO_ENGINE_SUBSTITUTION alone. NO_ENGINE_SUBSTITUTION is
# kept alongside it, since dropping it would change how a CREATE TABLE
# naming an unavailable engine behaves.
'connect_args': {
'charset': 'utf8mb4',
'init_command':
"SET SESSION sql_mode='STRICT_TRANS_TABLES,"
"NO_ENGINE_SUBSTITUTION'",
},
}
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production')

View File

@@ -0,0 +1,82 @@
"""Parsing a date out of a request body, strictly.
WHY THIS EXISTS
Two plugins carried an identical private `_parse_date` that swallowed the
error:
try:
return datetime.strptime(str(value)[:10], '%Y-%m-%d').date()
except (ValueError, TypeError):
return None
So an unparseable date and a deliberately cleared one produced the same
result, and the write went ahead. `PUT /api/warranty/<id>` with enddate
'not-a-date' answered 200 "Warranty updated" and stored NULL - the warranty
then vanished from every expiry surface, silently, on the strength of a typo.
The measuring-tool calibration date did the same thing and dropped the tool out
of the calibration report.
The rule here: an EXPLICIT empty value clears the field, and anything that is
not a date is refused. Those are different intents and they get different
answers. Callers turn `DateValueError` into a 400 naming the field, so the
person who mistyped it finds out at the point of the mistake rather than at the
next audit.
Accepted forms are deliberately narrow - 'YYYY-MM-DD', and the ISO-8601
datetime forms whose leading ten characters are a date, since that is what the
frontend's date inputs and every vendor API in the tree emit. Anything looser
is a guess about what somebody meant.
"""
from datetime import date, datetime
class DateValueError(ValueError):
"""A supplied value was neither empty nor a date this accepts.
Carries the field name so the error message can say which one, which is the
whole point of refusing rather than nulling.
"""
def __init__(self, field, value):
self.field = field
self.value = value
super().__init__(
'%s must be a date as YYYY-MM-DD (got %r)' % (field, value))
def parse_date(value, field='date'):
"""Return a date, or None for an explicitly empty value.
Raises DateValueError for anything else. None and '' mean "clear it";
they are the only values that produce None.
"""
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
if not isinstance(value, str):
# A number or a list is not a mistyped date, it is the wrong type.
raise DateValueError(field, value)
text = value.strip()
if not text:
return None
try:
return datetime.strptime(text[:10], '%Y-%m-%d').date()
except ValueError:
raise DateValueError(field, value)
def parse_date_fields(data, fields):
"""Parse several date fields out of a payload at once.
Only keys PRESENT in the payload are returned, so a caller can tell "not
mentioned" (leave it alone) from "sent as empty" (clear it) - a distinction
a plain `data.get(...)` throws away, and the reason the alias-clearing bug
existed on the PC form.
"""
return {field: parse_date(data[field], field)
for field in fields if field in data}