PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / diagnostics / ResponseBodyRewriteVerdict.php

ResponseBodyRewriteVerdict.php in 404 Solution trunk, at includes/diagnostics/ResponseBodyRewriteVerdict.php

124 lines 6.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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