Compare commits

...

2 Commits

Author SHA1 Message Date
56fc58b843 fix(tools): reject CSV metacharacters in flash_device.py inputs
device-id, location-id, wifi-ssid, and wifi-password were interpolated
directly into the NVS partition CSV. A value containing comma, double
quote, CR, or LF would split the field/row and silently provision the
wrong NVS keys — easiest concrete failure: a Wi-Fi password containing
a comma. Validate operator-supplied strings before generating the CSV.

Add an empty tools/__init__.py so the regression tests can import the
helper as 'tools.flash_device' (matches the existing 'server.*' test
pattern).

Found via adversarial review (run 2026-05-01-192928, gpt-5.5 reviewer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:44:57 -07:00
641ab29277 fix(server): reject inverted period_start/period_end in CameraRecord
A misbehaving or clock-broken device could submit period_end <=
period_start, polluting the camera_records table with zero-length or
inverted windows that corrupt downstream hourly analytics. Add a
Pydantic model_validator so the request is rejected at the API
boundary instead of silently persisting bad ranges.

Found via adversarial review (run 2026-05-01-191359, both reviewers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:44:57 -07:00
5 changed files with 62 additions and 1 deletions

View File

@@ -11,7 +11,7 @@ import sqlite3
from typing import List from typing import List
from fastapi import Depends from fastapi import Depends
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
class CameraRecord(BaseModel): class CameraRecord(BaseModel):
@@ -20,6 +20,12 @@ class CameraRecord(BaseModel):
entries: int = Field(ge=0) entries: int = Field(ge=0)
exits: int = Field(ge=0) exits: int = Field(ge=0)
@model_validator(mode="after")
def _period_order(self):
if self.period_end <= self.period_start:
raise ValueError("period_end must be strictly greater than period_start")
return self
class CameraEventsRequest(BaseModel): class CameraEventsRequest(BaseModel):
location_id: str location_id: str

View File

@@ -98,3 +98,15 @@ def test_negative_counts_rejected():
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
CameraRecord(period_start=1712000000, period_end=1712003600, CameraRecord(period_start=1712000000, period_end=1712003600,
entries=-1, exits=0) entries=-1, exits=0)
def test_inverted_period_rejected():
"""Pydantic should reject period_end <= period_start."""
from pydantic import ValidationError
from server.camera_endpoint import CameraRecord
with pytest.raises(ValidationError):
CameraRecord(period_start=1712003600, period_end=1712003600,
entries=0, exits=0)
with pytest.raises(ValidationError):
CameraRecord(period_start=1712003600, period_end=1712000000,
entries=0, exits=0)

0
tools/__init__.py Normal file
View File

View File

@@ -28,6 +28,25 @@ NVS_NAMESPACE = "doorcounter"
NVS_PARTITION_OFFSET = "0x9000" NVS_PARTITION_OFFSET = "0x9000"
NVS_PARTITION_SIZE = "0x5000" # matches firmware partition table (20KB) NVS_PARTITION_SIZE = "0x5000" # matches firmware partition table (20KB)
# Characters that would change the field/row structure of the NVS-CSV format
# (key,type,encoding,value). A value containing any of these would either
# split into more fields or add rows, silently provisioning the wrong keys.
_CSV_FORBIDDEN = (",", '"', "\n", "\r")
def _reject_csv_metacharacters(name, value):
"""Exit with an error if value contains a character that would corrupt
the NVS CSV. Used for operator-supplied strings (device id, location id,
WiFi credentials)."""
for c in _CSV_FORBIDDEN:
if c in value:
print(
f"Error: --{name} contains forbidden character {c!r}; "
f"this would corrupt the NVS partition CSV.",
file=sys.stderr,
)
sys.exit(1)
def build_nvs_csv(device_id, location_id, hmac_secret, def build_nvs_csv(device_id, location_id, hmac_secret,
wifi_ssid=None, wifi_pass=None, line_offset=50): wifi_ssid=None, wifi_pass=None, line_offset=50):
@@ -78,6 +97,13 @@ def main():
print("Error: --line-offset must be 0-100", file=sys.stderr) print("Error: --line-offset must be 0-100", file=sys.stderr)
sys.exit(1) sys.exit(1)
_reject_csv_metacharacters("device-id", args.device_id)
_reject_csv_metacharacters("location-id", args.location_id)
if args.wifi_ssid is not None:
_reject_csv_metacharacters("wifi-ssid", args.wifi_ssid)
if args.wifi_password is not None:
_reject_csv_metacharacters("wifi-password", args.wifi_password)
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
csv_path = os.path.join(tmp, "nvs.csv") csv_path = os.path.join(tmp, "nvs.csv")
bin_path = os.path.join(tmp, "nvs.bin") bin_path = os.path.join(tmp, "nvs.bin")

View File

@@ -0,0 +1,17 @@
import pytest
from tools.flash_device import _reject_csv_metacharacters
def test_clean_value_accepted():
"""A value with no metacharacters should pass without exiting."""
_reject_csv_metacharacters("device-id", "dc-0042")
_reject_csv_metacharacters("location-id", "retailer-123")
_reject_csv_metacharacters("wifi-ssid", "StoreWiFi-2.4GHz")
_reject_csv_metacharacters("wifi-password", "p@ssw0rd!~#$%^&*()_+-=:;<>?/")
@pytest.mark.parametrize("bad", ["Home,Network", 'pa"ss', "ssid\nfoo", "name\rbar"])
def test_metacharacter_rejected(bad):
with pytest.raises(SystemExit):
_reject_csv_metacharacters("wifi-ssid", bad)