Repository navigation
Let the host name the surface it is serving - #93
Conversation
Nothing on the wire between this library and Mailchimp says whether the process behind a call is a storefront render, an admin click or a cron, and PHP_SAPI cannot be made to say it: Magento's own cron entry point refuses to run under the CLI SAPI, so an installation running cron over HTTP reports as web today and every web figure we quote is contaminated by an unknown amount of cron. The host knows; it had no way to tell us. setSurface() takes the two tokens together and keeps them as instance state, beside the user agent rather than on a bucket. A bucket exists only once an API key has been seen, and the dispatch happens whether or not one ever is -- a store view with no key, or an account past MAX_BUCKETS, would have been exactly the processes whose surface went unnamed. A process serves one surface; a bucket is an account it reached. The action is attacker-controlled: Magento's page-cache block renderer writes the route, controller and action from an unauthenticated query parameter for the duration of a block render, and getFullActionName() is their concatenation. So the value is bounded here and rejected whole when it does not fit, never repaired -- stripping the bytes that failed out of `sales_order\x00view` yields `sales_orderview`, which nobody sent and which may be a route that exists. The charset is ASCII by the same pattern the receiver enforces, which settles a second problem as a consequence: json_encode() returns false on malformed UTF-8 and flush() drops a body that is not a string, so one hostile byte would have cost the whole report -- every count and every timing, for every bucket -- rather than just this field. The pattern ends at \z, not $. PHP's $ also matches before a final newline, so the receiver's own expression ported literally would accept one byte the receiver refuses, and that token would vanish with nothing recorded on either side.
dipola
left a comment
There was a problem hiding this comment.
Approving. The \z call is right — PHP's $ matches before a final newline and \z does not — and reject-whole with the length measured on the raw bytes before the charset test is the correct order.
Two suggestions, neither blocking.
1. An unrouted request composes the bare delimiters
getFullActionName() is route . '_' . controller . '_' . action. When nothing was routed all three parts are null and it returns __.
That value passes surfaceToken(), and it has to: underscore is inside [A-Za-z0-9_] because it is the separator. What comes through is a token that names nothing, is byte-identical on every installation that produces it, and looks exactly like a route name to anything that reads it later.
By the reasoning already in your description — the library bounds the shape — a value with no name in it is a shape question rather than a semantic one, so it belongs here too and not only at the call site. One more line beside the two checks already there:
if (!preg_match('/[A-Za-z0-9]/', $value)) {
return null;
}Worth writing the rule as "at least one character that is not a separator" rather than as anything about double underscores: mailchimp__ should be kept. It carries a real route id, so it says the route resolved and the controller and action did not, which is a true and readable thing to report.
2. A test for surfaceToken(), in the harness this repo already has
tools/tests/ plus the existing workflow give you a runner, an idiom and a CI step with no new dependency and no PHPUnit.
The \z is the case that most wants pinning. It is a deliberate departure from the far more familiar $, the difference shows up on exactly one byte, and the instinct of the next person editing that line will be to "normalise" it back — at which point nothing fails and the tokens quietly stop arriving. The table already in your description, asserted, plus one more run: step, closes that off permanently.
Raised in review. getFullActionName() composes route, controller and action with underscores between them, so a request that was never routed composes the separators alone -- `__`. That passes the charset test, and has to: underscore is in the set because it is the separator. What would arrive is a token that names nothing, byte-identical on every installation producing one, and indistinguishable from a route name to anything reading it later. The rule is "carries something that is not a separator", not anything about double underscores. `mailchimp__` is kept: it says the route resolved and the controller and action did not, which is true and worth reporting. The test exists for the `\z`. It is a deliberate departure from the far more familiar `$`, the difference shows up on exactly one byte, and the instinct of the next person editing that line is to normalise it back -- at which point nothing fails, the token stops arriving, the beacon still answers 202 and nothing anywhere records that anything was dropped. It now fails, on that one case. The workflow ran one gate and now runs two, so its name said less than it did. Renamed rather than left inaccurate; nothing requires the old name, as develop has no protection referencing it.
|
Both taken. CI green, five steps. 1.
|
|
Both good, and the rename is an improvement — keep it. A workflow named after one of the two gates it runs is a name that will mislead someone at exactly the wrong moment. Two things you did that I did not ask for and that are better than what I did: Asserting the reason, not just the behaviour. Keeping the filter at the call site as well. Your reasoning is the right one and sharper than mine: the library sees a shape, the call site knows why the value is meaningless — nothing was routed — and can decline to send the bytes at all. Two layers that know different things is not duplication. The mutation result is the part worth keeping in the commit record: before this, changing |
The library half of the area/action pair. The receiver half is live; the extension call site follows separately.
Why the library cannot work this out for itself
ctxis derived fromPHP_SAPI:climeans cron, anything else means web. But Magento's own cron entry point refuses to run under the CLI SAPI —pub/cron.phpexits ifphp_sapi_name() === 'cli'and then boots the framework'sCronapplication. An installation running cron over HTTP is a web-SAPI process doing a full sync, and it lands in the web lane. Every web-lane number is contaminated by an unknown amount of cron, and no amount of looking at our own traffic can separate them.The second reason is narrower: exactly two call sites reach the campaigns family — a storefront controller that fires on any page carrying a foreign
mc_cid, and an admin controller that fires only on an explicit click in the order view. Both emit an identicalcampaigns:404into a single last-write-wins slot, and both are web, soctxcannot separate them. The storefront one is the common case by construction, since it needs no merchant action at all — which means suppressing it as noise is the same edit as losing the merchant-facing one.Shape
setSurface($area, $action)— one call, both tokens, because the host knows them at the same moment and a single entry point is a singlemethod_exists()guard on the caller's side.Instance state, beside the user agent, not on a bucket. A bucket exists only once an API key has been seen:
$_currentis null for a store view with no key configured and for everything pastMAX_BUCKETS. The dispatch happened regardless, and those are precisely the processes whose surface we would most want named. A process serves one surface; a bucket is an account it reached.First non-empty wins, per token and independently. A valid area survives an action that does not pass, and a later valid action can still complete an earlier rejected one — but nothing can overwrite a value already taken.
Above the lean cut, with the other facts about the reporting process rather than about the account. A lean report is one sent by a process that never saw the account root, and what that process was doing is the one thing such a report can still say. It costs nothing to be wrong about this either:
sendMode()never returns lean off the CLI lane, and both reasons above are web.The action is attacker-controlled, and that shapes the rest
getFullActionName()is the plain concatenation of route, controller and action. Magento's page-cache block renderer — a frontendHttpGetActionInterfacecontroller, no admin session, no form key, gated only by anX-Requested-Withheader the caller sets — writes all three from a JSON query parameter for the duration of a block render.So:
sales_order\x00viewyieldssales_orderview— not what arrived, and possibly a route that exists. Absent says what happened; cleaned does not.Neither end can tell a forged but well-formed action name from a real one. What both bound is the shape, so the worst case is an action attributed to the wrong surface — not an action that carries anything into a consumer.
\z, not$The receiver's expression is
^[A-Za-z0-9_]+$in JavaScript, where$matches only at the end of input. PHP's$also matches immediately before a final newline. Measured:Ported literally, this library would have accepted exactly one byte the receiver refuses, and that token would have disappeared with nothing recorded at either end.
The charset settles a second problem for free
json_encode()returns false on malformed UTF-8, andflush()drops a body that is not a string. One hostile byte would therefore have cost the entire report — every count, every timing, for every bucket — not merely this field. An ASCII-only pattern makes that unreachable rather than handled.Demonstrated rather than argued, on a real envelope:
Verified
22 values through the real
envelope(), every rejection leaving the key absent — never a cleaned or clipped remnant — and never costing the rest of the envelope:frontend+checkout_index_indexsales_Order_View(stock mixed-case route)An area outside the receiver's closed set is passed through rather than rejected here. The set of areas is the receiver's to hold — duplicating it would mean re-releasing this library whenever the platform gains one, and a value that arrives and is dropped there is how we would learn that happened.
First-wins and independence, on the same path:
End to end on a running 2.4.8, through the real helper, a real API key and a real
rootcall rather than a fixture — and following the send rather than assuming it:The body is built by invoking
envelope()with the same argumentsflush()passes it — the real$cliand the modesendMode()actually returned — so those are the bytes that went out, not a reconstruction._sendsFailedis incremented bypost()on a curl error or any status outside 200-299, so zero is the transport confirming delivery from this side.Two more cases on the same install, with the send suppressed rather than sampled away:
The first is the no-regression case: an extension that knows nothing about this method produces the envelope it produced before, key for key. The second is §4.3's argument made rather than asserted — the surface is named before an API key exists, so before any bucket does, and it still arrives. On a bucket it would have been dropped.
tools/no-deprecated-calls.php: 64 files, 0 findings.Not in this PR
The extension call site, which needs two guards of its own:
method_exists()on this method, because an app/code installation pairs whichever library is present with whichever module is present, and a guard ongetFullActionName(), which is not onRequestInterfaceand does not exist on the CLI request implementation at all — where most of the API traffic lives.