mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-23 11:28:14 +00:00
fix(web): reject non-finite JSON numbers instead of raising
POST /api/v3/config/dim-schedule with {"dim_brightness": Infinity} answered
500. So did /api/v3/errors/clear with max_age_hours, and /api/v3/config/main
with multiplexing or row_address_type.
json.loads accepts Infinity/-Infinity/NaN by default -- they are not valid
JSON, but Python's parser emits them -- and Flask's get_json passes them
straight through. int(float('inf')) raises OverflowError, which is neither
ValueError nor TypeError, so validation blocks that carefully caught those let
it past and Flask turned it into a 500.
The status code was not the real damage. dim-schedule answered with
CONFIG_SAVE_FAILED and suggested "Check file permissions on config directory"
and "Check available disk space" for what was an invalid number. Every one of
these sites already had a correct 400 response written; they just never
reached it.
NaN already returned 400, because int(nan) raises ValueError. That is why this
only ever showed up for the infinities, and why it survived: the obvious test
case passes.
OverflowError is now caught alongside ValueError/TypeError at the 27 sites in
this file whose try block performs a numeric coercion. An AST sweep confirms
no int()/float() of request-derived data is left outside a block that catches
it.
Verified end to end through Flask's test client rather than by reasoning about
the parser: all four routes returned 500 before and 400 after.
Tests: five Infinity cases (which fail against the previous except tuples),
two NaN cases pinned so narrowing the tuple cannot quietly break them, and a
check that ordinary input is not rejected by the widened guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
This commit is contained in:
co-authored by
Claude Opus 5
parent
6b74506695
commit
0ca702bebf
@@ -0,0 +1,73 @@
|
||||
"""Non-finite JSON numbers must be rejected, not raise.
|
||||
|
||||
json.loads accepts Infinity/-Infinity/NaN by default (they are not valid JSON,
|
||||
but Python's parser emits them) and Flask's get_json passes them straight
|
||||
through. int(float('inf')) raises OverflowError, which is neither ValueError
|
||||
nor TypeError -- so validation blocks that carefully caught those let it
|
||||
through and Flask turned it into a 500.
|
||||
|
||||
The damage was not the status code. /config/dim-schedule answered with
|
||||
CONFIG_SAVE_FAILED and suggested "Check file permissions on config directory"
|
||||
and "Check available disk space" for what was actually an invalid number.
|
||||
|
||||
NaN already returned 400 (int(nan) raises ValueError), which is why this only
|
||||
showed up for the infinities.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
|
||||
#: (route, body) pairs that returned 500 before OverflowError was caught.
|
||||
NON_FINITE_CASES = [
|
||||
('/api/v3/config/dim-schedule', '{"dim_brightness": Infinity}'),
|
||||
('/api/v3/config/dim-schedule', '{"dim_brightness": -Infinity}'),
|
||||
('/api/v3/errors/clear', '{"max_age_hours": Infinity}'),
|
||||
('/api/v3/config/main', '{"multiplexing": Infinity}'),
|
||||
('/api/v3/config/main', '{"row_address_type": Infinity}'),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route,body", NON_FINITE_CASES)
|
||||
def test_infinity_is_a_client_error_not_a_server_error(api_v3_client, route, body):
|
||||
response = api_v3_client.post(route, data=body, content_type='application/json')
|
||||
assert response.status_code != 500, (
|
||||
f"{route} with {body} raised instead of validating"
|
||||
)
|
||||
assert 400 <= response.status_code < 500, (
|
||||
f"{route} answered {response.status_code}; expected a 4xx"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route,body", [
|
||||
('/api/v3/config/dim-schedule', '{"dim_brightness": NaN}'),
|
||||
('/api/v3/errors/clear', '{"max_age_hours": NaN}'),
|
||||
])
|
||||
def test_nan_is_also_a_client_error(api_v3_client, route, body):
|
||||
"""int(nan) raises ValueError so this path already worked -- pinned so a
|
||||
refactor that narrows the except tuple cannot quietly break it."""
|
||||
response = api_v3_client.post(route, data=body, content_type='application/json')
|
||||
assert 400 <= response.status_code < 500
|
||||
|
||||
|
||||
def test_a_valid_number_is_not_rejected_by_the_guard(api_v3_client):
|
||||
"""The widened except must not start swallowing ordinary input.
|
||||
|
||||
Asserting on 2xx is not possible here: every manager is a MagicMock, so
|
||||
the save path fails downstream whatever is posted. What this can show is
|
||||
that a valid number gets past *validation* -- it is not answered with a
|
||||
400, and nothing in the response mentions the coercion failing.
|
||||
"""
|
||||
response = api_v3_client.post(
|
||||
'/api/v3/config/dim-schedule',
|
||||
data='{"dim_brightness": 30}',
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code != 400, "a valid brightness was rejected"
|
||||
assert b'must be an integer' not in response.get_data()
|
||||
assert b'OverflowError' not in response.get_data()
|
||||
@@ -597,7 +597,7 @@ def save_dim_schedule_config():
|
||||
dim_brightness = 30
|
||||
else:
|
||||
dim_brightness = int(dim_brightness_raw)
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return error_response(
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
"dim_brightness must be an integer between 0 and 100",
|
||||
@@ -797,7 +797,7 @@ def save_main_config():
|
||||
}), 400
|
||||
try:
|
||||
target_fps = int(raw_target_fps)
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for target_fps: must be an integer"
|
||||
@@ -867,7 +867,7 @@ def save_main_config():
|
||||
mux_val = int(data['multiplexing'])
|
||||
if mux_val < 0 or mux_val > 22:
|
||||
return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400
|
||||
|
||||
# Validate pixel_mapper_config (free-form mapper string, e.g. "U-mapper;Rotate:90")
|
||||
@@ -885,7 +885,7 @@ def save_main_config():
|
||||
rat_val = int(data['row_address_type'])
|
||||
if rat_val < 0 or rat_val > 4:
|
||||
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400
|
||||
|
||||
# Handle hardware settings
|
||||
@@ -910,7 +910,7 @@ def save_main_config():
|
||||
if rp1_val not in (0, 1):
|
||||
return jsonify({'status': 'error', 'message': "rp1_rio must be 0 (PIO) or 1 (RIO)"}), 400
|
||||
current_config['display']['runtime']['rp1_rio'] = rp1_val
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return jsonify({'status': 'error', 'message': "rp1_rio must be 0 or 1"}), 400
|
||||
|
||||
# Handle checkboxes - coerce to bool to ensure proper JSON types
|
||||
@@ -963,7 +963,7 @@ def save_main_config():
|
||||
copies = None
|
||||
try:
|
||||
copies = int(data['double_sided_copies'])
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
if enabled:
|
||||
return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400
|
||||
if copies is not None and not (2 <= copies <= 8):
|
||||
@@ -1036,7 +1036,7 @@ def save_main_config():
|
||||
if data.get('vegas_extend_threshold_screens') not in ('', None):
|
||||
try:
|
||||
screens = float(data['vegas_extend_threshold_screens'])
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for vegas_extend_threshold_screens: "
|
||||
@@ -1053,7 +1053,7 @@ def save_main_config():
|
||||
if data.get('vegas_max_plugin_width_ratio') not in ('', None):
|
||||
try:
|
||||
ratio = float(data['vegas_max_plugin_width_ratio'])
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for vegas_max_plugin_width_ratio: "
|
||||
@@ -1101,7 +1101,7 @@ def save_main_config():
|
||||
continue
|
||||
try:
|
||||
int_value = int(raw_value)
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f"Invalid value for {field_name}: must be an integer"
|
||||
@@ -1153,7 +1153,7 @@ def save_main_config():
|
||||
if not (1024 <= port_val <= 65535):
|
||||
return jsonify({'status': 'error', 'message': "sync_port must be between 1024 and 65535"}), 400
|
||||
current_config['sync']['port'] = port_val
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return jsonify({'status': 'error', 'message': "sync_port must be an integer"}), 400
|
||||
|
||||
if "sync_follower_position" in data:
|
||||
@@ -1197,7 +1197,7 @@ def save_main_config():
|
||||
raw_value = data.pop(field)
|
||||
try:
|
||||
int_value = int(raw_value)
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return jsonify({'status': 'error',
|
||||
'message': f"Invalid duration for {field}: must be an integer"}), 400
|
||||
current_config['display']['display_durations'][field] = int_value
|
||||
@@ -1220,7 +1220,7 @@ def save_main_config():
|
||||
continue
|
||||
try:
|
||||
int_value = int(raw_value)
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return jsonify({'status': 'error',
|
||||
'message': f"Invalid duration for mode '{mode_key}': must be an integer"}), 400
|
||||
current_config['display']['display_durations'][mode_key] = int_value
|
||||
@@ -5118,7 +5118,7 @@ def save_plugin_config():
|
||||
converted_array.append(int(v))
|
||||
else:
|
||||
converted_array.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
converted_array.append(v)
|
||||
else:
|
||||
converted_array.append(v)
|
||||
@@ -5143,7 +5143,7 @@ def save_plugin_config():
|
||||
converted_array.append(int(v))
|
||||
else:
|
||||
converted_array.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
converted_array.append(v)
|
||||
else:
|
||||
converted_array.append(v)
|
||||
@@ -5180,7 +5180,7 @@ def save_plugin_config():
|
||||
converted_array.append(int(v))
|
||||
else:
|
||||
converted_array.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
converted_array.append(v)
|
||||
else:
|
||||
converted_array.append(v)
|
||||
@@ -5204,7 +5204,7 @@ def save_plugin_config():
|
||||
converted_array.append(int(v))
|
||||
else:
|
||||
converted_array.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
converted_array.append(v)
|
||||
else:
|
||||
converted_array.append(v)
|
||||
@@ -5371,7 +5371,7 @@ def save_plugin_config():
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
converted.append(int(v) if item_type == 'integer' else float(v))
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
converted.append(v)
|
||||
else:
|
||||
converted.append(v)
|
||||
@@ -5496,7 +5496,7 @@ def save_plugin_config():
|
||||
try:
|
||||
normalized[key] = int(value_stripped)
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
pass
|
||||
elif isinstance(value, (int, float)):
|
||||
normalized[key] = int(value)
|
||||
@@ -5514,7 +5514,7 @@ def save_plugin_config():
|
||||
try:
|
||||
normalized[key] = float(value_stripped)
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
pass
|
||||
elif isinstance(value, (int, float)):
|
||||
normalized[key] = float(value)
|
||||
@@ -5569,7 +5569,7 @@ def save_plugin_config():
|
||||
try:
|
||||
normalized_array.append(int(v))
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
pass
|
||||
elif isinstance(v, (int, float)):
|
||||
normalized_array.append(int(v))
|
||||
@@ -5579,7 +5579,7 @@ def save_plugin_config():
|
||||
try:
|
||||
normalized_array.append(float(v))
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
pass
|
||||
elif isinstance(v, (int, float)):
|
||||
normalized_array.append(float(v))
|
||||
@@ -5595,7 +5595,7 @@ def save_plugin_config():
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
normalized_array.append(int(v))
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
normalized_array.append(v)
|
||||
elif isinstance(v, (int, float)):
|
||||
normalized_array.append(int(v))
|
||||
@@ -5609,7 +5609,7 @@ def save_plugin_config():
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
normalized_array.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
normalized_array.append(v)
|
||||
else:
|
||||
normalized_array.append(v)
|
||||
@@ -5632,7 +5632,7 @@ def save_plugin_config():
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
normalized[key] = int(value)
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
normalized[key] = value
|
||||
else:
|
||||
normalized[key] = value
|
||||
@@ -5641,7 +5641,7 @@ def save_plugin_config():
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
normalized[key] = float(value)
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
normalized[key] = value
|
||||
else:
|
||||
normalized[key] = value
|
||||
@@ -6779,7 +6779,7 @@ def get_font_preview() -> tuple[Response, int] | Response:
|
||||
# Safe integer parsing for size
|
||||
try:
|
||||
size = int(request.args.get('size', 12))
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400
|
||||
|
||||
if not font_filename:
|
||||
@@ -8360,7 +8360,7 @@ def clear_old_errors():
|
||||
context={'provided_value': raw_max_age},
|
||||
status_code=400
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return error_response(
|
||||
error_code=ErrorCode.INVALID_INPUT,
|
||||
message="max_age_hours must be a valid integer",
|
||||
|
||||
Reference in New Issue
Block a user