Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -1674,7 +1674,7 @@ multi-host deployment must additionally front the API with a proxy/WAF limiter a
| Request body | `[store].max_upload_bytes` (the `/uploads` routes only) | 1 MiB elsewhere | per request | no | no | no | **stateless** — every route, in ASGI middleware | **413** over the cap, **400** on ambiguous CL+TE framing or an invalid `Content-Length`, **411** on a chunked body |
| OIDC pending flows | `[auth].oidc_flow_cache_max` (global), `DEFAULT_PER_IP_CAP` (per-IP, no knob), `oidc_flow_ttl_seconds` | 512 / 16 / 300 s | 300 s TTL | no | **yes** (512) | **yes** (16) | **in-process** — `GET /ui/oidc/start` — reject-when-full, never evict | 303 → `/ui/login?e=rate_limited`, WARNING-logged, **never** audited |
| WebAuthn pending ceremonies | `GLOBAL_PENDING_CAP`, `PER_USER_PENDING_CAP`, `CHALLENGE_TTL_SECONDS` (module constants, no knobs) | 4096 / 16 / 120 s | 120 s TTL | **yes** (16) | **yes** (4096) | no | **in-process** — every passkey registration + assertion ceremony | per-user: evicts that user's **own** oldest pending ceremony (silent); global: `ChallengeCacheFullError` naming the cause + the `admin_reset_mfa` recovery path |
| **Ingest plane** | `max_messages_per_second`, `message_burst` (MLLP inbound) | **off** | per message | no | no | no | **in-process** — one bucket per MLLP connection, so it neither coordinates across engine shards nor aggregates per peer | **exists but ships OFF, so unset there is still no volume bound.** When set, the listener **pauses reading** over budget so TCP back-pressures the sender: no message is dropped, refused, NAK'd or reordered (the count-and-log invariant forbids accept-and-drop, so a discarding limiter was never available). Bounded by the bucket deficit. The off default is **ruled, not accidental** — a rate on a clinical interface is only safe at a number from a real feed profile. **Not covered:** the raw-TCP inbound, and any per-peer bound (MLLP peers are unauthenticated, so the only key would be source IP, which NAT collapses). Other inbound caps are resource-only — `max_connections` (256), `receive_timeout` (60.0 s), `max_frame_bytes` (16 MiB), per-connection `max_message_bytes`, `source_ip_allowlist` |
| **Ingest plane** | `max_messages_per_second`, `message_burst` (MLLP inbound) | **NOT REACHABLE — read the note** | per message | no | no | no | **in-process** — one bucket per MLLP connection, so it neither coordinates across engine shards nor aggregates per peer | **THE PACER IS BUILT AND NO DOCUMENTED CONFIGURATION CAN TURN IT ON.** Read that before the rest of this cell. Neither key is a parameter of the `MLLP()` factory, and `connections.toml` routes through that same factory, so **neither the code-first nor the TOML surface can express them**. This row previously read state **"off"**, which every reader takes to mean "set it to on". **So there is currently NO message-RATE bound anywhere on the ingest plane.** — **This is NOT "unbounded intake", and that correction runs in the engine's favour:** several bounds ship **ON** and are listed at the end of this cell. They bound **SIZE and CONCURRENCY, not RATE**. — *What the pacer would do if it were reachable:* the listener **pauses reading** over budget so TCP back-pressures the sender; no message is dropped, refused, NAK'd or reordered (the count-and-log invariant forbids accept-and-drop, so a discarding limiter was never available). The off default was **ruled, not accidental** — a rate on a clinical interface is only safe at a number from a real feed profile — but **a ruled default and an unreachable setting are different things, and only the first was intended.** **Not covered even if reachable:** the raw-TCP inbound, and any per-peer bound (MLLP peers are unauthenticated, so the only key would be source IP, which NAT collapses). **Resource bounds that DO ship on** — `max_connections` (256), `receive_timeout` (60.0 s), `max_frame_bytes` (16 MiB), per-connection `max_message_bytes`, `source_ip_allowlist` |

**What these limits defend, and what they do not.** The full inventory of resource-demanding
functionality — including the surfaces that remain **unbounded** at this release — is
Expand Down
3 changes: 2 additions & 1 deletion messagefoundry/config/codeset_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
load_code_sets,
)
from messagefoundry.config.wiring import WiringError
from messagefoundry.controlchars import has_control_char
from messagefoundry.spreadsheet import SPREADSHEET_FORMULA_TRIGGERS, spreadsheet_safe

#: Extensions the loader recognises (the writer only ever *writes* ``.csv``; both are read).
Expand Down Expand Up @@ -302,7 +303,7 @@ def _validate_name(codesets_dir: Path, name: str) -> None:
# Reject control characters (NUL, tab, newline, DEL, …) before they reach a filesystem call: an
# embedded NUL makes Path.resolve() raise a bare ValueError the CLI's except clause can't catch
# (crashing with no JSON on stdout), and none belong in a bare file stem regardless.
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in name):
if has_control_char(name):
raise WiringError(f"code set name {name!r} must not contain control characters")
if "/" in name or "\\" in name:
raise WiringError(f"code set name {name!r} must not contain a path separator")
Expand Down
3 changes: 2 additions & 1 deletion messagefoundry/config/impact.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
build_reference_index,
)
from messagefoundry.config.wiring import Registry, WiringError
from messagefoundry.controlchars import has_control_char

__all__ = [
"LiteralEdit",
Expand Down Expand Up @@ -628,7 +629,7 @@ def _validate_new_name(config_dir: Path, target_kind: str, new: str) -> None:
# A name is embedded verbatim into a Python/TOML string literal; a quote, backslash, or control
# char would break out of the literal (or the value). Names are simple identifiers-in-practice —
# refuse anything that could corrupt the rewrite.
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in new):
if has_control_char(new):
raise WiringError(f"the new name {new!r} must not contain control characters")
if any(ch in new for ch in ("'", '"', "\\")):
raise WiringError(f"the new name {new!r} must not contain a quote or backslash")
Expand Down
56 changes: 56 additions & 0 deletions messagefoundry/controlchars.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2026 MessageFoundry Organization and contributors
"""The C0/DEL control-character test, written once (BACKLOG #1253).

WHAT THIS REPLACES. ``ord(ch) < 0x20 or ord(ch) == 0x7F`` was written out seven times across six
files -- two in ``transports/fhir.py`` and one each in ``config/codeset_edit.py``,
``config/impact.py``, ``transports/dicomweb.py``, ``transports/remotefile.py`` and
``transports/rest.py``. Every copy agreed, so nothing was mis-screened. The cost was future-tense
and is the one #1239 named: a later hardening applied to one copy silently does not apply to the
rest, and nothing reports the omission.

THIS SHARES THE PREDICATE, NOT THE ACTION, AND THAT DISTINCTION IS THE DESIGN. #1239 ruled out
"collapsing the call sites into one helper with a flag" because the differing wrappers are
appropriate: a raise suits a path context, a bool suits a filter, and the exceptions differ by layer
(``WiringError`` in config, a PHI-safe negative ACK in FHIR). So each call site keeps its own
refusal and its own message; only the TEST moves here. A flag parameter would have re-created the
coupling the item exists to remove, one indirection further away.

TWO ACTIONS ARE PRESERVED ON PURPOSE, and one of them must never be "simplified" into the other:

* REJECT -- six sites. A control character in a value that reaches a URL path, a header, a
filename or a config field is refused outright.
* STRIP -- ``transports/rest.py`` only, on a message-derived header VALUE. That is defensible
rather than a second instance of the mutation pattern the owner ruled against in #1238: that
ruling turns on ``basename()`` converting a path into a valid-but-DIFFERENT target, handing an
attacker a real file. A header value has no such property -- removing CR/LF cannot redirect a
request anywhere -- and ``rest.py`` already REJECTS a header NAME failing its RFC 7230 token
check. Name-rejected, value-stripped, which is principled.

DELIBERATELY NOT FOLDED IN. ``parsing/sniff.py`` tests the same code points but is a genuinely
different predicate: it is byte-wise rather than character-wise and subtracts an allowlist, because
a text sniffer must tolerate tab, CR and LF. Folding it in would change its behaviour.

THE POINT IS THE COPYING PRACTICE, not the seven known lines. If you need this test, import it.
"""

from __future__ import annotations


#: C0 controls (U+0000-U+001F) plus DEL (U+007F). NOT a general "is this printable" test: it is
#: deliberately blind to C1 (U+0080-U+009F) and to Unicode separators, because every call site
#: screens values destined for byte-oriented sinks -- a request line, a header, a path -- where C0
#: and DEL are the injection alphabet. Widening it is a behaviour change at seven call sites at
#: once, which is exactly the leverage this module exists to provide; make it deliberately.
def has_control_char(text: str) -> bool:
"""True if ``text`` contains any C0 control character or DEL."""
return any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in text)


def strip_control_chars(text: str) -> str:
"""``text`` with every C0 control and DEL removed.

The strip arm, used where a value must be neutralised rather than refused. See the module
docstring: this is NOT the general remedy and must not be substituted for a rejection.
"""
return "".join(ch for ch in text if not (ord(ch) < 0x20 or ord(ch) == 0x7F))
3 changes: 2 additions & 1 deletion messagefoundry/transports/dicomweb.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from typing import Any

from messagefoundry.config.models import ConnectorType, Destination
from messagefoundry.controlchars import has_control_char
from messagefoundry.transports.base import (
DeliveryError,
DeliveryResponse,
Expand Down Expand Up @@ -93,7 +94,7 @@ def _reject_url_control_chars(value: str, field: str) -> None:
CR/LF in ``study_uid`` would let it split the request line; urllib would reject it with a bare
``ValueError`` at send. Surface it as a clear construction-time ``ValueError`` (caught at
``check``/dry-run as a ``WiringError``) — PHI-safe (names the field, never the value)."""
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in value):
if has_control_char(value):
raise ValueError(f"DICOMweb {field} contains an illegal control character")


Expand Down
5 changes: 3 additions & 2 deletions messagefoundry/transports/fhir.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from typing import Any

from messagefoundry.config.models import ConnectorType, Destination
from messagefoundry.controlchars import has_control_char
from messagefoundry.parsing.fhir import FhirPeek, FhirPeekError
from messagefoundry.transports.base import (
DeliveryError,
Expand Down Expand Up @@ -175,7 +176,7 @@ def _reject_control_chars(value: str, field: str) -> str:
``ValueError`` that would otherwise escape ``send()`` as an 'internal error'. Surface it as a
permanent ``NegativeAckError`` (a retry re-sends the same body) with a PHI-safe message (the field
name only, never the value)."""
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in value):
if has_control_char(value):
raise NegativeAckError(
f"FHIR {field} contains an illegal control character",
code="bad-request-value",
Expand All @@ -198,7 +199,7 @@ def _reject_config_control_chars(value: str, setting: str, where: str = "destina
and the read executor -- and #1241's subject is precisely the asymmetry of screening one and not
its sibling.
"""
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in value):
if has_control_char(value):
raise ValueError(f"FHIR {where} {setting!r} contains an illegal control character")
return value

Expand Down
3 changes: 2 additions & 1 deletion messagefoundry/transports/remotefile.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
relax_verify_expiry,
resolve_trust_anchor,
)
from messagefoundry.controlchars import has_control_char
from messagefoundry.transports.base import (
DeliveryError,
DestinationConnector,
Expand Down Expand Up @@ -118,7 +119,7 @@ def _is_contained_name(name: object) -> bool:
return False
if "/" in name or "\\" in name:
return False
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in name):
if has_control_char(name):
return False
# A drive-relative path ("C:x.hl7") resolves against the drive's CWD on Windows and contains no
# separator, so the checks above cannot see it. Two chars, ASCII letter, then a colon.
Expand Down
3 changes: 2 additions & 1 deletion messagefoundry/transports/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
is_loopback_hop_host,
relax_verify_expiry,
)
from messagefoundry.controlchars import strip_control_chars
from messagefoundry.transports.base import (
DeliveryError,
DeliveryResponse,
Expand Down Expand Up @@ -108,7 +109,7 @@ def _strip_header_control_chars(value: str) -> str:
"""Neutralize a message-derived header VALUE (header-injection safety, #68): strip every C0 control
(< 0x20 — incl. CR/LF) and DEL (0x7F) so the value can never split the request line or inject an
extra header. Returns the value with those bytes removed (a single, safe header value)."""
return "".join(ch for ch in value if not (ord(ch) < 0x20 or ord(ch) == 0x7F))
return strip_control_chars(value)


# --- captured HTTP response headers (BACKLOG #154, ADR 0013 amendment) -----------------------------
Expand Down
13 changes: 13 additions & 0 deletions scripts/docs/backlog_status_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,19 @@ def scan(


def main(argv: list[str] | None = None) -> int:
# THIS MODULE MUST CARRY NON-cp1252 CHARACTERS, so it hardens the stream instead of losing them
# (BACKLOG #1030). The docstring below is argparse's description and quotes the machine-parsed
# banner alphabet CLAUDE.md section 11 protects; remediation text that cannot show an author the
# character it wants added is not actionable. On a stock Windows cp1252 console `--help` therefore
# raised UnicodeEncodeError on U+2705 before this line -- measured, not theorised.
#
# `errors="replace"` is deliberate and is NOT a way of tolerating mangled text: the codec is what
# was wrong, and it is fixed here to UTF-8. Replacement is the backstop for a stream that cannot
# be reconfigured at all, so one exotic codepoint can never again truncate a gate's output
# mid-sentence. Scoped to the CLI entry point: importers get their own stdout untouched.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")

root = Path(__file__).resolve().parents[2]
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
Expand Down
Loading
Loading