* fix(hopper): bound bridge request frames like the Cutter bridge The Hopper bridge read each request with an unbounded readline, so a peer that never wrote a newline kept Hopper allocating for as long as it kept writing. The Cutter bridge already refuses a frame above MAX_FRAME_BYTES; the Hopper bridge had no request ceiling at all. Read the request line with a 16 MiB bound and refuse a line that fills the limit without a terminator, using the existing invalid_request diagnostic before closing the connection. Such a line cannot be answered or resynchronized, because its remainder would be read as the next request. The response direction is unchanged, so large decompilation results still flow, and every dispatchable method carries small bounded parameters. The stalled-line case keeps its connection open instead of half-closing it, so the test pins the exact ceiling: a bound one byte larger waits for a byte the peer never sends and fails instead of passing quietly. Validation: npm run check:fast (typecheck, lint) passed uncached, and the new boundary test passed against the real bridge over socket pairs at the frame limit and above it. Removing only the read bound makes the same test fail, and widening the bound to one byte past the limit fails it too. The Hopper boundary and bridge-packaging suites report the same 17 failing files and 126 failing tests before and after this change on this Windows host; those failures are POSIX-only probes (Unix socket directories, os.statvfs) unrelated to this diff. Native macOS Hopper and real-client traffic were not run. * fix(bridges): exclude line delimiter from payload limit * test(hopper): cover partial request at EOF --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
133 lines
4.4 KiB
Python
133 lines
4.4 KiB
Python
"""Exercise the shipped bridge's request-frame bound over real socket pairs.
|
|
|
|
Four cases share one loaded bridge module:
|
|
|
|
- ``legit``: an ordinary authenticated request is served.
|
|
- ``at_limit``: a request with an exactly-limit JSON payload plus LF is served,
|
|
so framing does not consume a payload byte.
|
|
- ``past_limit``: a peer writes one byte past the limit and never terminates the
|
|
line. An unbounded line read cannot answer this at all, which is what makes
|
|
Hopper allocate for as long as the peer keeps writing.
|
|
- ``stalled_line``: a peer exceeds the payload limit without a terminator,
|
|
stops writing, and keeps the connection open. The bridge must refuse it
|
|
without waiting for EOF, then close.
|
|
- ``eof_partial``: an authenticated, otherwise valid short JSON request has no
|
|
LF and the peer half-closes its write side. The bridge must reject the
|
|
incomplete frame without dispatching it, then close.
|
|
"""
|
|
import json
|
|
import socket
|
|
import sys
|
|
import threading
|
|
|
|
from hopperBridgeFacadeProbe import load_bridge
|
|
|
|
READ_TIMEOUT_SECONDS = 5
|
|
|
|
|
|
def _next_response(file):
|
|
while True:
|
|
line = file.readline()
|
|
if not line:
|
|
return {"closed": True}
|
|
value = json.loads(line.decode("utf-8"))
|
|
if "event" not in value:
|
|
return value
|
|
|
|
|
|
def serve(bridge, line, read_after, half_close_write=False):
|
|
"""Send one raw line and record the bridge's answer."""
|
|
server, client = socket.socketpair()
|
|
worker = threading.Thread(target=bridge["_serve_connection"], args=(server,))
|
|
worker.daemon = True
|
|
worker.start()
|
|
file = client.makefile("rwb")
|
|
client.settimeout(READ_TIMEOUT_SECONDS)
|
|
outcome = {}
|
|
dispatch_count = len(bridge["_probe_dispatches"])
|
|
try:
|
|
file.write(line)
|
|
file.flush()
|
|
if half_close_write:
|
|
client.shutdown(socket.SHUT_WR)
|
|
outcome["reply"] = _next_response(file)
|
|
if read_after:
|
|
outcome["next"] = _next_response(file)
|
|
except OSError as error:
|
|
outcome["reply"] = {"socket_error": type(error).__name__}
|
|
finally:
|
|
try:
|
|
file.close()
|
|
finally:
|
|
client.close()
|
|
worker.join(2)
|
|
outcome["thread_released"] = not worker.is_alive()
|
|
outcome["dispatch_calls"] = (
|
|
len(bridge["_probe_dispatches"]) - dispatch_count
|
|
)
|
|
return outcome
|
|
|
|
|
|
def request_line(bridge, params):
|
|
request = {
|
|
"id": 1,
|
|
"token": bridge["REA_TOKEN"],
|
|
"method": "health",
|
|
"params": params,
|
|
}
|
|
return json.dumps(request, separators=(",", ":")).encode("utf-8")
|
|
|
|
|
|
def main():
|
|
bridge = load_bridge(sys.argv[1])
|
|
bridge.update(REA_TOKEN="owned-token", REA_RUN_ID="owned-run")
|
|
bridge["_probe_dispatches"] = []
|
|
|
|
def dispatch(method, params):
|
|
bridge["_probe_dispatches"].append((method, params))
|
|
return {"run_id": bridge["REA_RUN_ID"], "ready": True}
|
|
|
|
bridge["_dispatch"] = dispatch
|
|
limit = bridge.get("MAX_REQUEST_BYTES")
|
|
if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:
|
|
json.dump({"limit": limit}, sys.stdout)
|
|
return
|
|
|
|
legit = serve(bridge, request_line(bridge, {}) + b"\n", False)
|
|
|
|
# The payload may fill the limit; the framing LF is additional.
|
|
padding = limit - len(request_line(bridge, {"padding": ""}))
|
|
at_limit_line = request_line(bridge, {"padding": "p" * padding}) + b"\n"
|
|
assert len(at_limit_line) == limit + 1, len(at_limit_line)
|
|
at_limit = serve(bridge, at_limit_line, False)
|
|
|
|
# One byte past the limit, still unterminated and still open.
|
|
past_limit = serve(bridge, b"x" * (limit + 1), False)
|
|
|
|
# The payload limit plus one byte, without a terminator. The peer stays
|
|
# connected so the bounded read must reject it without waiting for EOF.
|
|
stalled_line = serve(bridge, b"x" * (limit + 1), True)
|
|
|
|
# This is a valid authenticated request, but omitting LF makes it an
|
|
# incomplete frame. EOF must terminate the read and produce a rejection;
|
|
# the application dispatch must never see it.
|
|
eof_partial = serve(
|
|
bridge, request_line(bridge, {}), True, half_close_write=True
|
|
)
|
|
|
|
json.dump(
|
|
{
|
|
"limit": limit,
|
|
"legit": legit,
|
|
"at_limit": at_limit,
|
|
"past_limit": past_limit,
|
|
"stalled_line": stalled_line,
|
|
"eof_partial": eof_partial,
|
|
},
|
|
sys.stdout,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|