mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 21:42:05 +00:00
138 lines
4.5 KiB
Python
138 lines
4.5 KiB
Python
"""`fgex pyfilters`: the way an operator tries a filter before it goes near a service.
|
|
|
|
Run as they would run it — the `fgex` script, as a process of its own, in front of a
|
|
real service — because every part of that path has broken on its own at some point: the
|
|
file watcher that starts the proxy, the child process it runs in (Python 3.14 starts
|
|
those with `forkserver`, which imports the script again), and the filtering itself.
|
|
Nothing else in the suite runs it, so a release could ship a simulator that no longer
|
|
simulates and nobody would find out until they needed it.
|
|
|
|
What it has to agree with is production. A filter that refuses here refuses there, clean
|
|
traffic comes back, and traffic the HTTP parser cannot read is carried — the library's
|
|
default — rather than refused as it used to be.
|
|
"""
|
|
|
|
import os
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from helpers.net import free_port
|
|
|
|
FGEX = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "fgex-lib", "fgex"))
|
|
|
|
RAW = """from firegex.pyfilters import pyfilter, ACCEPT, REJECT
|
|
from firegex.pyfilters.models import RawPacket
|
|
|
|
@pyfilter
|
|
def refuse(packet: RawPacket):
|
|
return REJECT if b"BLOCKME" in packet.data else ACCEPT
|
|
"""
|
|
|
|
HTTP = """from firegex.pyfilters import pyfilter, ACCEPT, REJECT
|
|
from firegex.pyfilters.models import HttpRequest
|
|
|
|
@pyfilter
|
|
def refuse(req: HttpRequest):
|
|
return REJECT if "BLOCKME" in (req.url or "") else ACCEPT
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def echo():
|
|
"""A service that answers with whatever it was sent. Returns its port."""
|
|
server = socket.socket()
|
|
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
server.bind(("127.0.0.1", free_port()))
|
|
server.listen(16)
|
|
|
|
def answer(conn):
|
|
try:
|
|
while data := conn.recv(4096):
|
|
conn.sendall(data)
|
|
except OSError:
|
|
pass
|
|
finally:
|
|
conn.close()
|
|
|
|
def serve():
|
|
while True:
|
|
try:
|
|
conn, _ = server.accept()
|
|
except OSError:
|
|
return
|
|
threading.Thread(target=answer, args=(conn,), daemon=True).start()
|
|
|
|
threading.Thread(target=serve, daemon=True).start()
|
|
yield server.getsockname()[1]
|
|
server.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def simulator(echo, tmp_path):
|
|
"""Start `fgex pyfilters` on a filter file in front of `echo`. Returns an asker."""
|
|
started = []
|
|
|
|
def _start(code: str):
|
|
path = tmp_path / "filter.py"
|
|
path.write_text(code)
|
|
port = free_port()
|
|
proc = subprocess.Popen(
|
|
[sys.executable, FGEX, "pyfilters", str(path), "127.0.0.1", str(echo),
|
|
"--from-port", str(port)],
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
started.append(proc)
|
|
deadline = time.time() + 20
|
|
while time.time() < deadline:
|
|
if proc.poll() is not None:
|
|
pytest.fail(f"fgex pyfilters exited: {proc.stdout.read()}")
|
|
try:
|
|
socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
|
|
break
|
|
except OSError:
|
|
time.sleep(0.1)
|
|
else:
|
|
pytest.fail("fgex pyfilters never started listening")
|
|
|
|
def ask(payload: bytes) -> bytes:
|
|
try:
|
|
with socket.create_connection(("127.0.0.1", port), timeout=3) as conn:
|
|
conn.sendall(payload)
|
|
return conn.recv(4096)
|
|
except OSError:
|
|
return b""
|
|
|
|
return ask
|
|
|
|
yield _start
|
|
for proc in started:
|
|
# Interrupted rather than killed: that is how an operator stops it, and it is the
|
|
# path that takes the proxy's own process down with it.
|
|
proc.send_signal(signal.SIGINT)
|
|
try:
|
|
proc.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
proc.wait()
|
|
|
|
|
|
def test_a_raw_filter_refuses_what_it_should_and_carries_the_rest(simulator):
|
|
ask = simulator(RAW)
|
|
assert ask(b"hello") == b"hello"
|
|
assert ask(b"carrying BLOCKME") == b""
|
|
|
|
|
|
def test_an_http_filter_reads_requests_and_carries_what_is_not_http(simulator):
|
|
ask = simulator(HTTP)
|
|
clean = b"GET /fine HTTP/1.1\r\nHost: x\r\n\r\n"
|
|
assert ask(clean) == clean
|
|
assert ask(b"GET /BLOCKME HTTP/1.1\r\nHost: x\r\n\r\n") == b""
|
|
# Not a refusal: `invalid_encoding_action` is ACCEPT unless the file says otherwise.
|
|
garbage = b"\x16\x03\x01 not a request\r\n\r\n"
|
|
assert ask(garbage) == garbage
|