Skip to content

v2.50.1

Choose a tag to compare

@github-actions github-actions released this 08 Sep 11:42
· 9 commits to main since this release

Security

  • The remote chose which arm of accept_inbound() its message landed in, and only one arm consulted the allowlist. 2.50.0 gated the REPLY arm on allow_replies_to, and the reply bit is the remote's to set — so on a target-less HTTP_Out a spoke cleared TM_RESPONSE, fell through to the untargeted pass-through, and addressed any node in the graph. That pass-through is what the release notes told operators to avoid by pointing the egress at a Null; the gate should not have depended on their doing so. One rule now: an ADDRESSED message must name a declared destination whatever type bits ride with it, and nothing declared still means nothing addressed passes. The target arm keeps its job — stamping the target onto UNADDRESSED output — and its own refusal is gone, subsumed by the declaration.
  • A spoke could vary its TO to defeat the drop-rate limiter. The refusal reason interpolated the remote's path, and Node::drop_message() KEYS print_less_often() on that reason — so every distinct TO logged again and added a permanent entry to the process's Core::$recent_log_timers. The reason is constant now. drop_message() itself also keyed on the message's TYPE, a bitmask of eleven flags the SENDER picks, which is 2048 keys and 2048 timers from any one drop site; the type moved to the unkeyed tail beside FROM, TO and the payload, so the key is the caller's reason and nothing the message decides. The rendered line is unchanged.
  • allow_replies_to matched the TO's HEAD segment, which made every declaration a prefix rule. 2.50.0 stored the head on the reasoning that a head is what Router_Node peels. But the Router peels the head and then dispatches on the REST, so allow_replies_to _router admitted _router/<anything> and re-opened the whole graph through the list that exists to bound it — and _command_interpreter and _fleet are the same shape. It matches the WHOLE path now, exactly: a declared settings-sync admits a TO of settings-sync and nothing else, and a remote that legitimately answers on a deeper path is declared at that path. This supersedes the head-matching paragraph in 2.50.0's note.
  • $ matches before a trailing newline, and nothing in this tree was D-anchored. The SECURITY-labelled HANDLER_NAME_PATTERN on Job_Intake and Job_Worker_Node let "work\n" through into a line-oriented log; event-logger-nodes carries the third copy. The sweep took every anchored pattern that VALIDATES a whole token — the two handler gates, Core::canonical_decimal() (where "42\n" is not the canonical spelling of 42, which is the function's entire job), the worker-id gates in Bootstrap, Cli, Spawn_Coordinator and SSE_Out_Node, Service_CI_Node's verb-argument default, Layouts_CI_Node::ID_PATTERN, the segment-filename patterns in Partition_Node, Log_Node and Workers_CI_Node, the scaffold CLI's class and slug gates, the memcached server-line gate in the settings UI, Dumper_Node's session filter and Topology_Analyzer's verb and key gates. Deliberately NOT anchored: Flame_Tree::PATTERN_{START,COMPLETE}, Request_Builder_Node's method line and Shell_Node::VAR_GRAMMAR all PARSE a line rather than validate a token — there the trailing newline is incidental, and D would make the line stop matching and lose the capture.
  • The segment directory was world-traversable. Segments are 0600, but a 0755 directory still publishes the listing, and a segment name is an offset — how much was written, and when it rotated. One ensure_segment_dir() now owns all three sites and TIGHTENS what it finds, because a mode passed to mkdir applies once: an install already on disk would otherwise keep 0755 forever, and nothing else probes permissions (Health_Checks has no fileperms call). It masks group and other off rather than setting 0700, so a deliberately read-only directory is left alone, and only the LEAF tightens — mkdir with $recursive applies the mode to every ancestor it creates, and a 0700 logs_dir locks out a deployment whose web request and worker run as different uids. The rotated-segment permission test pins the process umask to 022 besides: a runner already at 0077 masked the bits itself and passed against the defect the test exists to catch.
  • Command_Interpreter_Node executed a TM_COMMAND|TM_ERROR in the JS runtime. PHP masks TM_ERROR beside TM_RESPONSE; the JS half masked only the latter. _respond() addresses a reply TO the request's FROM, so an errored command that arrived with an empty FROM came back as a fresh command — unbounded synchronous recursion. Mirrored.

Fixed

  • A batch every message of which was refused at the boundary answered as if it had routed. stamp_message() warns to stderr, the request-scope stderr sink writes that warning into _output, and the client read a 200 carrying a stderr frame that retires no ask — so a client tracking replies by ID waited for one that would never come. A refused message now gets a TM_RESPONSE|TM_ERROR frame carrying its own ID, addressed back along the FROM it sent.
    That warning was ALSO spending the status line. fill() decides the status on its first write and the status is spent for good, so a batch whose first message overflowed MAX_FROM_SIZE and whose second was an unauthorized command answered 200 and the 401 never ran — $out->reset() then cleared the latch and a second status_header() went out behind the body. dispatch() now ASKS the new Node::can_stamp() instead of attempting the stamp, and emits both the diagnostic and the frame after the batch, once the latch has settled. The latch is deliberately not raised for a boundary refusal: an overflowing path is not an auth failure, and a 401 makes HTTP_Out_Node forget its session and re-handshake, which for a path the client keeps sending is a loop.
  • settings-sync.tsl documents the per-spoke egress in full: the Null, the connect_node, and the allow_replies_to declaration without which the acks are dropped. 2.50.0 required both and the topology said neither.
  • ADR-18 documented the refusal 2.50.0 deleted. It now states the rule that shipped: a spent remainder costs the entry its cache slot, not the read, and what a re-materialized entry is warmed for is the backing's to state.