netget/server/mysql/packet_limit.rs
1//! A bound on the largest MySQL packet a stranger can make this server buffer.
2//!
3//! # The defect
4//!
5//! `opensrv-mysql` 0.7.0 has no maximum-packet check anywhere. Its `PacketReader::next_async`
6//! grows `self.bytes` by `max(1 MiB, len * 2)` until a whole *logical* packet has been
7//! buffered, and `packet()` assembles that logical packet with
8//! `nom::multi::fold_many0(fullpacket, …)` — an unbounded fold over 16 MiB continuation
9//! packets, each of which extends a `Vec`. So the peer decides how much this process
10//! allocates, and it decides before authentication and before any model call: the very first
11//! thing a client sends is its handshake response, read through exactly this path.
12//!
13//! Worse, the server *publishes* a bound. `opensrv-mysql` answers
14//! `SELECT @@max_allowed_packet` itself, with `67108864`, without ever consulting the shim —
15//! and never compares an incoming packet against it. A published bound that is not enforced is
16//! worse than no bound: a client sizes its writes by the number it was given.
17//!
18//! # The bound
19//!
20//! [`MAX_PACKET_BYTES`] is that same 67108864, so the number the server publishes and the
21//! number it enforces are one number. The refusal is MySQL's own: error **1153
22//! `ER_NET_PACKET_TOO_LARGE`**, SQLSTATE `08S01`, which is precisely what a real MySQL server
23//! sends when `max_allowed_packet` is exceeded, carrying the message a real server carries.
24//!
25//! # Where it is enforced
26//!
27//! Here, in a reader that sits *beneath* `opensrv-mysql` and decides each packet from the
28//! length the peer **declared** in its 4-byte header, before that packet's payload is read and
29//! therefore before anything is allocated for it. Bounding what has already arrived would be
30//! the NATS `HPUB` mistake — there the limit was applied to `total − header`, so `header ==
31//! total` passed every check and thirty bytes on the wire bought a 4 GB buffer.
32//!
33//! For a chained packet the accumulation is what is bounded, not each fragment: a fragment
34//! whose declared length is exactly [`FULL_PACKET_PAYLOAD`] means "more follows", so the
35//! logical packet's declared size is the running sum, and it is that sum which is compared
36//! against the bound at every header.
37
38use std::pin::Pin;
39use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
40use std::sync::Arc;
41use std::task::{Context, Poll};
42
43use tokio::io::{AsyncRead, ReadBuf};
44
45/// Largest logical MySQL packet this server will read, in payload bytes.
46///
47/// Exactly the value `opensrv-mysql` answers `SELECT @@max_allowed_packet` with, and it is
48/// chosen for that reason rather than for being a round number: the point of this bound is
49/// that the server stops publishing a ceiling it does not apply. Enforcing anything *smaller*
50/// than the published value would leave the same mismatch pointing the other way — a client
51/// that sized a write by `@@max_allowed_packet` would be refused at a length the server had
52/// told it was acceptable.
53pub const MAX_PACKET_BYTES: usize = 67_108_864;
54
55/// Payload length that means "this packet is a fragment; the next one continues it".
56///
57/// 2^24 − 1, the largest value the 3-byte little-endian length header can carry. A packet
58/// declaring exactly this is never the end of a logical packet — that is the protocol's own
59/// framing rule, and it is the construct that makes `fold_many0` unbounded.
60pub const FULL_PACKET_PAYLOAD: usize = 0x00FF_FFFF;
61
62/// What the limiting reader tells the connection task after it has refused.
63///
64/// A reader can only return an `io::Error`, and an `io::Error` is indistinguishable from a
65/// read timeout or a reset by the time `run_on` has turned it into its own error type. The
66/// connection task needs three things to answer in MySQL's vocabulary — that the bound was the
67/// reason, the sequence id the refusal must carry, and the size to log — so they are recorded
68/// here rather than parsed back out of an error string.
69#[derive(Debug, Default)]
70pub struct PacketLimitTrip {
71 tripped: AtomicBool,
72 /// Sequence id of the header that crossed the bound. The ERR packet answering it must
73 /// carry this plus one, or a conforming client discards the reply as out of order.
74 sequence: AtomicU8,
75 /// Declared size of the logical packet at the moment it crossed, for the log line.
76 declared: AtomicUsize,
77}
78
79impl PacketLimitTrip {
80 /// Whether the bound refused a packet on this connection.
81 pub fn tripped(&self) -> bool {
82 self.tripped.load(Ordering::SeqCst)
83 }
84
85 /// Sequence id the refusal must carry: one past the header that crossed the bound.
86 pub fn reply_sequence(&self) -> u8 {
87 self.sequence.load(Ordering::SeqCst).wrapping_add(1)
88 }
89
90 /// Declared size of the logical packet that crossed the bound.
91 pub fn declared_bytes(&self) -> usize {
92 self.declared.load(Ordering::SeqCst)
93 }
94
95 fn record(&self, sequence: u8, declared: usize) {
96 self.sequence.store(sequence, Ordering::SeqCst);
97 self.declared.store(declared, Ordering::SeqCst);
98 self.tripped.store(true, Ordering::SeqCst);
99 }
100}
101
102/// An `AsyncRead` that refuses a MySQL packet larger than its bound, before the payload is read.
103///
104/// It buffers nothing of its own beyond a four-byte header: bytes are handed straight through
105/// to the caller and the framing is tracked alongside them. That is what makes it safe to put
106/// beneath a crate that does its own buffering — the decision is taken on the header, so the
107/// crate underneath never sees the payload it would have allocated for.
108pub struct PacketLimitReader<R> {
109 inner: R,
110 limit: usize,
111 trip: Arc<PacketLimitTrip>,
112 /// Bytes of the current 4-byte packet header seen so far.
113 header: [u8; 4],
114 header_len: usize,
115 /// Payload bytes still to pass through before the next header begins.
116 payload_remaining: usize,
117 /// Declared payload bytes accumulated so far for the logical packet being assembled.
118 /// Non-zero only while a chain of [`FULL_PACKET_PAYLOAD`] fragments is in progress.
119 logical_declared: usize,
120}
121
122impl<R> PacketLimitReader<R> {
123 /// Wrap `inner` with the production bound.
124 pub fn new(inner: R, trip: Arc<PacketLimitTrip>) -> Self {
125 Self::with_limit(inner, MAX_PACKET_BYTES, trip)
126 }
127
128 /// Wrap `inner` with an arbitrary bound.
129 ///
130 /// Exists so a test can drive the framing decision at a size that fits in a test rather
131 /// than having to move 64 MiB to reach the production ceiling. It is **not** a knob:
132 /// nothing in `src/` calls this with anything but [`MAX_PACKET_BYTES`], because a bound
133 /// decided by configuration is a bound decided by whoever writes the configuration.
134 pub fn with_limit(inner: R, limit: usize, trip: Arc<PacketLimitTrip>) -> Self {
135 Self {
136 inner,
137 limit,
138 trip,
139 header: [0u8; 4],
140 header_len: 0,
141 payload_remaining: 0,
142 logical_declared: 0,
143 }
144 }
145
146 /// Give the wrapped reader back, framing checks and all state dropped.
147 ///
148 /// Used for the lingering drain after a refusal: draining through the limiter would trip
149 /// it again on the very bytes it is discarding.
150 pub fn into_inner(self) -> R {
151 self.inner
152 }
153
154 /// Track `bytes` through the framing, refusing at the first header that crosses the bound.
155 ///
156 /// Returns `Err(())` on refusal, having recorded the sequence id and the declared size.
157 fn observe(&mut self, bytes: &[u8]) -> Result<(), ()> {
158 let mut i = 0usize;
159 while i < bytes.len() {
160 if self.payload_remaining > 0 {
161 let take = self.payload_remaining.min(bytes.len() - i);
162 self.payload_remaining -= take;
163 i += take;
164 continue;
165 }
166
167 let need = 4 - self.header_len;
168 let take = need.min(bytes.len() - i);
169 self.header[self.header_len..self.header_len + take]
170 .copy_from_slice(&bytes[i..i + take]);
171 self.header_len += take;
172 i += take;
173 if self.header_len < 4 {
174 // A header split across two reads. Nothing is decided until all four bytes
175 // are in hand — deciding on a partial length is how a bound comes to be
176 // applied to the wrong number.
177 break;
178 }
179 self.header_len = 0;
180
181 let declared =
182 u32::from_le_bytes([self.header[0], self.header[1], self.header[2], 0]) as usize;
183 let sequence = self.header[3];
184 let logical = self.logical_declared.saturating_add(declared);
185
186 if logical > self.limit {
187 self.trip.record(sequence, logical);
188 return Err(());
189 }
190
191 self.payload_remaining = declared;
192 // A fragment of exactly FULL_PACKET_PAYLOAD continues into the next packet, so the
193 // logical size carries over. Anything shorter ends the logical packet.
194 self.logical_declared = if declared == FULL_PACKET_PAYLOAD {
195 logical
196 } else {
197 0
198 };
199 }
200 Ok(())
201 }
202}
203
204impl<R: AsyncRead + Unpin> AsyncRead for PacketLimitReader<R> {
205 fn poll_read(
206 self: Pin<&mut Self>,
207 cx: &mut Context<'_>,
208 buf: &mut ReadBuf<'_>,
209 ) -> Poll<std::io::Result<()>> {
210 let this = self.get_mut();
211 let before = buf.filled().len();
212 match Pin::new(&mut this.inner).poll_read(cx, buf) {
213 Poll::Ready(Ok(())) => {
214 let filled = buf.filled().len();
215 if filled > before {
216 // Copied rather than borrowed because `observe` takes `&mut self` while
217 // `buf` borrows from the caller; the slice is one read's worth, never a
218 // packet's worth, so this is bounded by the caller's buffer.
219 let fresh = buf.filled()[before..filled].to_vec();
220 if this.observe(&fresh).is_err() {
221 return Poll::Ready(Err(std::io::Error::new(
222 std::io::ErrorKind::InvalidData,
223 "MySQL packet exceeds max_allowed_packet",
224 )));
225 }
226 }
227 Poll::Ready(Ok(()))
228 }
229 other => other,
230 }
231 }
232}
233
234/// The ERR packet a peer gets when its packet crosses [`MAX_PACKET_BYTES`].
235///
236/// Laid out by hand because it is a wire packet, and matching a real MySQL server byte for
237/// byte is the whole point: 3-byte little-endian payload length, sequence id, `0xFF` marking
238/// an ERR packet, error number 1153 little-endian, the `#` SQL-state marker, the five
239/// characters of SQLSTATE `08S01`, and the message text MySQL itself uses. A client that
240/// reads only the error number sees 1153; one that classifies by SQLSTATE sees the
241/// communication-link-failure class; one that shows the operator a string sees the same
242/// sentence it would see from mysqld.
243///
244/// `sequence` must be one past the header that was refused. MySQL numbers packets within a
245/// command, and a reply whose sequence does not follow the request is discarded by a
246/// conforming client as "packet out of order" — so getting this wrong turns a clear refusal
247/// back into the silence this bound exists to replace.
248pub fn packet_too_large_err(sequence: u8) -> Vec<u8> {
249 const MESSAGE: &[u8] = b"Got a packet bigger than 'max_allowed_packet' bytes";
250 const ER_NET_PACKET_TOO_LARGE: u16 = 1153;
251
252 let mut payload = Vec::with_capacity(9 + MESSAGE.len());
253 payload.push(0xFF);
254 payload.extend_from_slice(&ER_NET_PACKET_TOO_LARGE.to_le_bytes());
255 payload.push(b'#');
256 payload.extend_from_slice(b"08S01");
257 payload.extend_from_slice(MESSAGE);
258
259 let len = payload.len();
260 let mut packet = Vec::with_capacity(4 + len);
261 packet.push((len & 0xFF) as u8);
262 packet.push(((len >> 8) & 0xFF) as u8);
263 packet.push(((len >> 16) & 0xFF) as u8);
264 packet.push(sequence);
265 packet.extend_from_slice(&payload);
266 packet
267}