Summary
TenantMiddleware binds whatever string arrives in the configured tenant header. Nothing checks its length or character set before it becomes current_tenant_id, so every MultiTenantMixin read filters on it and every write stamps it. tenant_id is VARCHAR(50), so a header longer than 50 characters makes the first stamped insert fail with a StringDataRightTruncation on Postgres, surfaced as an unhandled 500. Any other junk (control characters, whitespace, a 10 KB value) is accepted as a tenant name as well.
Observed
simple_module_hosting/middleware.py:191-196 (0.0.26):
if tenant_id is None and self.header:
header_value = Headers(scope=scope).get(self.header)
if header_value:
tenant_id = header_value
request.state.tenant_id = tenant_id
if tenant_id is not None:
token = current_tenant_id.set(tenant_id)
Reproduction: a host with SM_MULTI_TENANT=true and SM_TENANT_HEADER=X-Tenant-ID, any module whose table carries MultiTenantMixin, and an anonymous request that creates a row:
curl -X POST http://localhost:8000/api/<module>/... -H "X-Tenant-ID: $(python3 -c 'print("a"*60)')" ...
-> 500, sqlalchemy.exc.DBAPIError ... StringDataRightTruncationError: value too long for type character varying(50)
Expected
The middleware validates the header against the column's own contract (at most 50 characters, a printable identifier) and treats an invalid value as no tenant (or answers 400), instead of binding it.
Why a module cannot work around it
The header is consumed and bound before any module code runs. A module can re-check request.state.tenant_id and refuse the request (the records module does, with a ^[A-Za-z0-9][A-Za-z0-9_.:-]{0,49}$ pattern), but the contextvar has already been set for the rest of the pipeline and every other adopter of the mixin has to repeat the check.
Proposed API
Validate in TenantMiddleware: reject values longer than MultiTenantMixin's max_length or containing characters outside a documented identifier set, and expose the pattern from simple_module_db so modules and the middleware agree on it.
Context
Found while making the records module (antosubash/smpy_modules#37) multi-tenant; design doc docs/plans/2026-09-23-records-multitenancy.md there, gap L16.
Summary
TenantMiddlewarebinds whatever string arrives in the configured tenant header. Nothing checks its length or character set before it becomescurrent_tenant_id, so everyMultiTenantMixinread filters on it and every write stamps it.tenant_idisVARCHAR(50), so a header longer than 50 characters makes the first stamped insert fail with aStringDataRightTruncationon Postgres, surfaced as an unhandled 500. Any other junk (control characters, whitespace, a 10 KB value) is accepted as a tenant name as well.Observed
simple_module_hosting/middleware.py:191-196(0.0.26):Reproduction: a host with
SM_MULTI_TENANT=trueandSM_TENANT_HEADER=X-Tenant-ID, any module whose table carriesMultiTenantMixin, and an anonymous request that creates a row:Expected
The middleware validates the header against the column's own contract (at most 50 characters, a printable identifier) and treats an invalid value as no tenant (or answers 400), instead of binding it.
Why a module cannot work around it
The header is consumed and bound before any module code runs. A module can re-check
request.state.tenant_idand refuse the request (the records module does, with a^[A-Za-z0-9][A-Za-z0-9_.:-]{0,49}$pattern), but the contextvar has already been set for the rest of the pipeline and every other adopter of the mixin has to repeat the check.Proposed API
Validate in
TenantMiddleware: reject values longer thanMultiTenantMixin'smax_lengthor containing characters outside a documented identifier set, and expose the pattern fromsimple_module_dbso modules and the middleware agree on it.Context
Found while making the records module (antosubash/smpy_modules#37) multi-tenant; design doc
docs/plans/2026-09-23-records-multitenancy.mdthere, gap L16.