| 1 |
<?php |
| 2 |
|
| 3 |
|
| 4 |
if (!defined('ABSPATH')) { |
| 5 |
exit; |
| 6 |
} |
| 7 |
|
| 8 |
/** |
| 9 |
* Whether anything between this server and this browser REWROTE a response |
| 10 |
* body. |
| 11 |
* |
| 12 |
* Its input is the joined byte accounting from |
| 13 |
* ABJ_404_Solution_ResponseBodyDeliveryEvidence -- how many body bytes the |
| 14 |
* server wrote for one response against how many the browser's Resource |
| 15 |
* Timing says arrived -- and it has two consumers: the evidence record itself |
| 16 |
* (so a support payload read months later states the conclusion even when the |
| 17 |
* `interpret` response never reached the browser) and |
| 18 |
* ABJ_404_Solution_CanaryLadderInterpretation::interpret(), which folds it into |
| 19 |
* the ladder matrix. Two consumers over one input is what makes it a module |
| 20 |
* rather than a section of the ladder, the same way |
| 21 |
* ABJ_404_Solution_DetachAbVerdict is. |
| 22 |
* |
| 23 |
* It is deliberately NOT part of the ladder's own comparison logic. Every |
| 24 |
* other rule in that matrix infers a cause by comparing two probes against |
| 25 |
* each other; this one is a direct physical measurement of a single response, |
| 26 |
* in bytes, and it is therefore not vetoed by a failed same-phase control: a |
| 27 |
* control that also failed says nothing about whether these two byte counts |
| 28 |
* differ. |
| 29 |
* |
| 30 |
* Pure and side-effect free, so the rule is directly testable; the caller |
| 31 |
* journals the result. |
| 32 |
*/ |
| 33 |
final class ABJ_404_Solution_ResponseBodyRewriteVerdict { |
| 34 |
|
| 35 |
/** |
| 36 |
* How far the browser's delivered byte count may sit from the server's |
| 37 |
* emitted byte count before the body is called rewritten in transit. |
| 38 |
* |
| 39 |
* ZERO, from first principles rather than from caution. Both numbers count |
| 40 |
* the same thing in the same unit: PHP's `json_encode` result is the exact |
| 41 |
* octet length of the body the plugin wrote, and Resource Timing defines |
| 42 |
* `decodedBodySize` as the octet size of the payload body AFTER any |
| 43 |
* content-codings are removed. Compression, chunked framing and header |
| 44 |
* bytes are all outside both counts by definition, so on an unmodified |
| 45 |
* response they are equal byte for byte, and any tolerance at all would |
| 46 |
* only be a window for a rewrite to hide in. |
| 47 |
* |
| 48 |
* The one thing that legitimately makes the two differ is the canary |
| 49 |
* ladder's `stream` step, whose leading whitespace block is echoed outside |
| 50 |
* json_encode(). That is answered by ACCOUNTING for those bytes |
| 51 |
* (ABJ_404_Solution_ResponseBodyDeliveryEvidence adds the journaled |
| 52 |
* `streamWhitespaceBytes` to the emitted count), never by widening this |
| 53 |
* constant: exact accounting keeps the 2048-byte case detectable, while |
| 54 |
* 2048 bytes of slack would have hidden a 2048-byte rewrite. |
| 55 |
*/ |
| 56 |
const TOLERANCE_BYTES = 0; |
| 57 |
|
| 58 |
/** |
| 59 |
* The verdict over a list of emitted/delivered comparisons. |
| 60 |
* |
| 61 |
* A response that arrives complete and unparseable at a different byte |
| 62 |
* count than the server wrote has been altered after this plugin encoded |
| 63 |
* it: by a proxy or CDN that appended to HTML it mistook the JSON for, an |
| 64 |
* optimizer that "minifies" every response, or -- on a SAPI with no |
| 65 |
* connection detach -- another PHP component echoing after our own echo. |
| 66 |
* The verdict deliberately does not try to separate those: what it proves |
| 67 |
* is that the bytes the browser received are not the bytes this plugin |
| 68 |
* wrote, which is the fact that was missing, and the remaining candidates |
| 69 |
* are all outside this plugin either way. Support report 2026-08-27 |
| 70 |
* (plugin 4.3.4, Azure App Service) is the case: 3072 bytes emitted for |
| 71 |
* the `stream` canary, 6089 delivered, `parsererror`, |
| 72 |
* `truncated_on_arrival = false`. |
| 73 |
* |
| 74 |
* Every comparison with either half missing is counted as unknown and |
| 75 |
* reaches no verdict. An absent `decodedBodySize` is a finding about the |
| 76 |
* BROWSER -- no Resource Timing entry, a timing-restricted response, a |
| 77 |
* cleared buffer -- and never evidence that the body arrived intact, so |
| 78 |
* unknown can only ever leave `causal` false, never make it true. |
| 79 |
* |
| 80 |
* @param array<int, mixed> $comparisons Rows from |
| 81 |
* ABJ_404_Solution_ResponseBodyDeliveryEvidence::comparisonsIn(). Only |
| 82 |
* `step`, `emitted_bytes` and `delivered_bytes` are read; unknown fields |
| 83 |
* are tolerated so the persisted diagnostic shape stays additive. |
| 84 |
* @param int $maxStepChars Bound on each step name copied into the |
| 85 |
* verdict, so an unrecognised step reported by a hostile client cannot |
| 86 |
* grow the record. |
| 87 |
* @return array{bodyRewrittenInTransitCausal: bool, |
| 88 |
* bodyRewrittenInTransitSteps: string, bodySizeComparisonsMismatched: int, |
| 89 |
* bodySizeComparisonsMatched: int, bodySizeComparisonsUnknown: int} |
| 90 |
* Every field is scalar on purpose: the ladder copies only scalars into |
| 91 |
* the stage trace, and WHICH response was rewritten is the half of this |
| 92 |
* finding that makes it actionable. |
| 93 |
*/ |
| 94 |
public static function fromComparisons(array $comparisons, int $maxStepChars = 32): array { |
| 95 |
$rewritten = array(); |
| 96 |
$matched = 0; |
| 97 |
$unknown = 0; |
| 98 |
foreach ($comparisons as $comparison) { |
| 99 |
if (!is_array($comparison)) { |
| 100 |
continue; |
| 101 |
} |
| 102 |
$emitted = $comparison['emitted_bytes'] ?? null; |
| 103 |
$delivered = $comparison['delivered_bytes'] ?? null; |
| 104 |
if (!is_numeric($emitted) || !is_numeric($delivered)) { |
| 105 |
$unknown++; |
| 106 |
continue; |
| 107 |
} |
| 108 |
if (abs((int)$delivered - (int)$emitted) > self::TOLERANCE_BYTES) { |
| 109 |
$step = is_scalar($comparison['step'] ?? null) ? (string)$comparison['step'] : ''; |
| 110 |
$rewritten[] = substr($step, 0, max(1, $maxStepChars)); |
| 111 |
continue; |
| 112 |
} |
| 113 |
$matched++; |
| 114 |
} |
| 115 |
return array( |
| 116 |
'bodyRewrittenInTransitCausal' => $rewritten !== array(), |
| 117 |
'bodyRewrittenInTransitSteps' => implode(',', $rewritten), |
| 118 |
'bodySizeComparisonsMismatched' => count($rewritten), |
| 119 |
'bodySizeComparisonsMatched' => $matched, |
| 120 |
'bodySizeComparisonsUnknown' => $unknown, |
| 121 |
); |
| 122 |
} |
| 123 |
} |
| 124 |
|