v2.53.0
Security
-
allowed_usersgated the admin menus and nothing else, so a site that set it was not getting what the setting looks like. The list was consulted in exactly one place,Admin::current_user_allowed(), which fronted the menu registrations, therender_*_page()calls and the twoadmin_post_handlers. Every REST permission callback and every capability-gated verb decided onCapabilities::can()/require()instead, and neither read the list — so an administrator the operator deliberately excluded lost the menus and kept the entire control plane. The path was short: open devtools on any wp-admin page for thewp_restnonce,POST /newspack-nodes/v1/auth— which needs onlyCapabilities::can( READ )and clamps the granted scope to the caller's own capabilities, so an administrator gets a MANAGE-scoped signing key — then post a signedtopologies saveto/commandand write a.tslthe next worker spawn executes. The check now lives inCapabilities::can(), the one gate every surface already funnels through, so/command,/auth,/messages/stream,/log/stream, the spawn endpoint's external path, every Service CI verb and the admin pages all inherit it from one place. It is checked AFTER the capability, keeping the property the setting was documented with — a demoted account loses access with no edit here — and it applies to an AUTHENTICATED actor only: with no login there is nothing to match, so WP-CLI without--user, worker processes and WP-Cron are never narrowed, and a populated list cannot disarm the fleet or a CLI verb.Spawn_Controller's internal HMAC path returns before it reachescan()at all, so a worker reviving a peer holds no WordPress user and needs none. A site that already setsallowed_usersmust add any SERVICE account that reaches it over REST — the log aggregator's hub user, anHTTP_Outcredential another site holds in its Vault — or that account starts answering 401 where it used to pass.wp nodes hub-userwarns about exactly that: it consults the list after setting the role and, when a populated one omits the login, names the login and the config key to add it to. It does not refuse — the operator may be editing the list next. The list read fails CLOSED insidecan()rather than propagating:Config::value()raises on an invalid config file, an unreadableLOCAL_NEWSPACK_NODES_CONFor an unregistered key, andcan()is what four RESTpermission_callbacks answer with and whatAuto_Tuner_Node::fill()asks on the request path, so a throw there would be an uncaught 500 in place of a 403 and an ADR-13 violation. It refuses the role and reports the reason throughCore::print_less_often()instead; the\InvalidArgumentExceptioncap_for()raises on a brokennewspack_nodes/capability_mapfilter is a different contract and still propagates. One narrower change rides along: a scalarallowed_users, which is a config typo rather than an absent list, is read as a one-login allowlist, where the admin gate treated any non-array as "no list" and admitted every capable user; nuclear-gyrobase fixed the identical fail-open in its own copy of this rule. Residual, and it is small: a command SESSION authenticates by HMAC rather than by WordPress user, so a key minted before the exclusion stays cryptographically valid until its TTL expires (60s..86400s,Command_Auth::bounded_ttl(), defaultSESSION_TTL_S) — but it buys nothing, becauseHTTP_In_Node::check_permission()runscan( READ )on every/commandPOST before a signature is examined, so the excluded user is refused at the door on the very next request. The one window that does outlive the change is an SSE stream already open:check_permission()runs once at connect, so an established/messages/streamor/log/streamconnection keeps delivering until it drops. -
A control character in untrusted text reached an operator's terminal unrendered, and an anonymous web visitor could put one there. A request whose URL, Referer or User-Agent carried raw
0x1Bbytes lands inwp-content/debug.logthrough any PHP notice;debugis a built-inLog_Sourcesentry needing no configuration, so an operator runningtaillog debuginwp nodes clihad those bytes fwritten verbatim to their TTY — a\033]0;…\007retitling the window, an\033[2Jclearing the screen, and a\rrewriting the line already read, so the log could show one thing and the terminal another. The same applied to a worker-set prompt, whichDumper_Node::fill()writes from an attached worker'spromptresponse and hands to readline unexamined. NewCore::terminal_safe()now renders every C0 control character and DEL as a visible<XX>token — reverse-videoed on a real terminal, bare in a pipe, a file or a test capture — at all four places untrusted text reaches a terminal:Stdout_Node::write()(whichwp nodes reqgrepwrites through as well),TTY_Out_Node's redraw andwrite_prompt(), and the promptTTY_In_Nodeinstalls into readline. It RENDERS rather than strips, because a stripped byte hides the attack from whoever is reading the log, and rather than refuses, because a log tail legitimately holds whatever the log holds. Newline and tab pass through and\rdoes not. The C1 block renders in BOTH of the encodings a terminal acts on, as the codepoint either way —<9B>, never<C2><9B>, because the codepoint is what names the control an operator has to recognize. A lone0x80-0x9Fbyte is 8-bit CSI or OSC on an xterm, rxvt or screen outside UTF-8 mode —LANG=Cis enough — soUser-Agent: \x9B2Jcleared the screen exactly as\x1B[2Jdid.\xC2followed by that same byte is well-formed UTF-8 for the very same codepoint, and a UTF-8-mode terminal acts on what it decodes: VTE — GNOME Terminal, Tilix, Terminator, xfce4-terminal — dispatches U+009B from its ground state straight intoCSI_ENTRYand U+009D intoOSC_STRING, so\xC2\x9B2Jcleared the screen too, and on VTE that two-byte form was the ONLY reachable form, since it requires UTF-8 and never sees the lone byte as a control; the scan claims that two-byte sequence ahead of the general two-byte branch that would otherwise pass it through as an ordinary character. Everything else well-formed still passes byte-identical,\xC2\xA0one codepoint above the block included, and a0x80-0x9Fcontinuation byte inside any longer sequence with it. A failed render pass throws, where the(string)cast onpreg_replace_callback()'s null turned a PCRE error into an empty write, dropping the very line the tail was opened to show. Rendering is the default on every message path —wp nodes reqgrep, a Dumper's line, an interpreter reply — andStdout_Node::write_raw()is the single opt-out, for a caller composing a control sequence on purpose; the only one in the tree isShell_Node'sclearbuiltin, which otherwise printed<1B>[2J<1B>[Hinstead of clearing the screen. The prompt renders BARE at all three sites that draw it, payload text keeping reverse video on a terminal: highlighting is how the renderer marks an anomaly inside data the operator is reading, and a prompt redrawn behind every async write would flicker between two looks on readline's next forced redisplay.CLI::cli_safe(), which stripped, is gone: its three terminal-bound error messages render through the same helper.
Removed
- BREAKING:
Admin\Admin::current_user_allowed()is gone; callCapabilities::can( Capabilities::MANAGE ). With the allowlist insidecan()the method wasCapabilities::can( MANAGE )under a second name, and a wrapper carrying a rule that also lives elsewhere is the drift this codebase keeps paying for. All eight substrate call sites, the bundledexample-ai-newsletter,docs/writing-a-dashboard.mdanddocs/stability.md's frozen-surface list move with it — andCapabilitiesis now DECLARED on that list as item 12, since three consumers callcan()directly, event-logger-nodes' admin also callscap_for(), and every consumer service CI compiles the three role constants into itsnode_schema(). The reference in item 10 pointed at a frozen surface the document never named. A deleted public method cannot degrade. The substrate necessarily ships FIRST — a consumer pins the substrate tag, so the tag exists before the consumer built against it — and an older consumer calling this name fatals withCall to undefined methodrather than losing a feature, which is whatPartition_Node::locate_by()already cost us as an uncaught 500 on every dashboard request. One consumer calls it: newspack-intelligence, every release through 0.9.9, from three admin hooks — the Publisher Insights menu, the Settings submenu, and the dashboard enqueue onadmin_enqueue_scripts, which fires on every wp-admin page. Itsversion_at_least( '2.25.0' )floor does not help; the handshake passes, the plugin wires itself up, and it fatals. Intelligence is updated in the same pass and must be released beside this substrate. No other plugin in the family calls it — event-logger-nodes has a same-named method on its OWNAdminclass, unrelated and untouched.