mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 21:42:05 +00:00
1096 lines
59 KiB
Python
Executable File
1096 lines
59 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""The unified service model, against a live instance.
|
|
|
|
Two layers, exercised separately and together: a network layer that says how traffic is
|
|
intercepted, and a chain of filters that says what happens to it. The point of the tests
|
|
is that the two really are independent — the same filters block on either transport, and
|
|
the chain can be reordered and edited without the service being recreated.
|
|
"""
|
|
|
|
import argparse
|
|
import base64
|
|
import secrets
|
|
import socket
|
|
import time
|
|
|
|
from utils.colors import colors, puts, sep
|
|
from utils.firegexapi import FiregexAPI
|
|
from utils.tcpserver import TcpServer
|
|
from utils.tls_helpers import (capture_device_present, capture_on,
|
|
generate_self_signed_cert_key, tls_alpn_choice,
|
|
tls_connect_send_recv)
|
|
|
|
# Written against the documented API: parameters annotated with a model, verdicts from
|
|
# the library. It is the same code the NFQUEUE binary would run, which is the property
|
|
# worth testing — there is one Python API, not one per transport.
|
|
PYFILTER_CODE = """from firegex.pyfilters import pyfilter, ACCEPT, REJECT
|
|
from firegex.pyfilters.models import RawPacket
|
|
|
|
|
|
@pyfilter
|
|
def refuse_marker(packet: RawPacket):
|
|
return REJECT if b"PYBLOCK" in packet.data else ACCEPT
|
|
"""
|
|
|
|
# One file, two filters: one that wants parsed HTTP and one that wants raw payloads.
|
|
# Nothing declares a protocol — asking for an HttpRequest is what makes this an HTTP
|
|
# filter file, and the two annotations coexist because `http` provides both. This used
|
|
# to be impossible to save: the backend sent the *service's* protocol to the datapath,
|
|
# so an HTTP filter on a TCP service was refused for asking for an HttpRequest, which is
|
|
# the only thing an HTTP filter does.
|
|
HTTP_PYFILTER_CODE = """from firegex.pyfilters import pyfilter, ACCEPT, REJECT, ExceptionAction
|
|
from firegex.pyfilters.models import HttpRequest, RawPacket
|
|
|
|
# The stand-in service echoes whatever it is sent, so the reply to an HTTP request is
|
|
# not itself a valid HTTP response. The default is to refuse a stream the parser cannot
|
|
# read; here that is the test's own doing rather than the filter's, so it is turned off.
|
|
# (Setting this used to be silently ignored, which is how the gate that read it was
|
|
# found to be checking the wrong enum.)
|
|
FGEX_INVALID_ENCODING_ACTION = ExceptionAction.ACCEPT
|
|
|
|
|
|
@pyfilter
|
|
def refuse_traversal(request: HttpRequest):
|
|
return REJECT if "../" in (request.url or "") else ACCEPT
|
|
|
|
|
|
@pyfilter
|
|
def look_at_the_bytes(packet: RawPacket):
|
|
return ACCEPT
|
|
"""
|
|
|
|
# A datagram filter: only RawPacket, which is the one model that does not need a stream
|
|
# underneath it. Everything else — an assembled TCP stream, a parsed HTTP message —
|
|
# declines to be built on a datagram, so a filter asking for one would never be called.
|
|
UDP_PYFILTER_CODE = """from firegex.pyfilters import pyfilter, ACCEPT, REJECT
|
|
from firegex.pyfilters.models import RawPacket
|
|
|
|
|
|
@pyfilter
|
|
def refuse_marker(packet: RawPacket):
|
|
return REJECT if b"PYDENY" in packet.data else ACCEPT
|
|
"""
|
|
|
|
|
|
|
|
exit_code = 0
|
|
|
|
|
|
class UdpEcho:
|
|
"""A UDP echo service, and a client that talks to it through firegex.
|
|
|
|
Deliberately not the TCP helper with the protocol swapped: a datagram exchange has
|
|
no connection to establish, so "was it refused" is "did an answer come back before
|
|
the timeout" rather than "did the socket close".
|
|
"""
|
|
|
|
def __init__(self, port: int, ipv6: bool = False):
|
|
self.port = port
|
|
self.ipv6 = ipv6
|
|
self.sock = None
|
|
self.thread = None
|
|
|
|
def start(self):
|
|
# A thread rather than a process: an echo loop on a socket needs no isolation,
|
|
# and closing the socket is a cleaner way to stop it than killing a child.
|
|
import socket as s
|
|
import threading
|
|
|
|
self.sock = s.socket(s.AF_INET6 if self.ipv6 else s.AF_INET, s.SOCK_DGRAM)
|
|
self.sock.setsockopt(s.SOL_SOCKET, s.SO_REUSEADDR, 1)
|
|
self.sock.bind(("::1" if self.ipv6 else "127.0.0.1", self.port))
|
|
|
|
def serve():
|
|
while True:
|
|
try:
|
|
data, peer = self.sock.recvfrom(65535)
|
|
except OSError:
|
|
return # the socket was closed; that is how this loop ends
|
|
try:
|
|
self.sock.sendto(data, peer)
|
|
except OSError:
|
|
return
|
|
|
|
self.thread = threading.Thread(target=serve, daemon=True)
|
|
self.thread.start()
|
|
time.sleep(0.3)
|
|
|
|
def stop(self):
|
|
if self.sock:
|
|
self.sock.close()
|
|
self.sock = None
|
|
if self.thread:
|
|
self.thread.join(timeout=2)
|
|
self.thread = None
|
|
|
|
def exchange(self, payload: bytes, timeout: float = 1.5):
|
|
"""Send one datagram and return the answer, or `None` if none came back."""
|
|
import socket as s
|
|
sock = s.socket(s.AF_INET6 if self.ipv6 else s.AF_INET, s.SOCK_DGRAM)
|
|
sock.settimeout(timeout)
|
|
try:
|
|
sock.sendto(payload, ("::1" if self.ipv6 else "127.0.0.1", self.port))
|
|
return sock.recvfrom(65535)[0]
|
|
except (TimeoutError, OSError):
|
|
return None
|
|
finally:
|
|
sock.close()
|
|
|
|
|
|
MARKER = b"answered-by-your-own-proxy"
|
|
|
|
|
|
def gets_through(server, payload: bytes, attempts: int = 5) -> bool:
|
|
"""Does this traffic reach the service and come back?
|
|
|
|
Retried, for the same reason `reaches_proxy` below is: one TCP exchange is a noisy
|
|
way to observe a filter's decision, and a connection that fails for its own reasons
|
|
is indistinguishable from one a filter refused. Retrying cannot turn a real block
|
|
into a pass — a filter that refuses this payload refuses it on every attempt — so
|
|
this removes noise without weakening the check.
|
|
"""
|
|
for _ in range(attempts):
|
|
if server.sendCheckData(payload):
|
|
return True
|
|
time.sleep(0.3)
|
|
return False
|
|
|
|
|
|
def reaches_proxy(external, attempts: int = 6) -> bool:
|
|
"""Did traffic aimed at the service land on the operator's proxy instead?
|
|
|
|
The proxy answers with a marker while the real service echoes, so the reply says
|
|
which of the two the connection actually reached.
|
|
|
|
Retried for a couple of seconds: the suite runs the transports back to back on the
|
|
same ports, and a listener from the previous case can still be going away. Waiting
|
|
for the answer to settle does not weaken the check — a hand-off that never works
|
|
still never answers with the marker.
|
|
"""
|
|
for attempt in range(attempts):
|
|
try:
|
|
external.connect_client()
|
|
try:
|
|
external.send_packet(b"hello", server_reply=MARKER)
|
|
if external.recv_packet() == MARKER:
|
|
return True
|
|
finally:
|
|
external.close_client()
|
|
except OSError:
|
|
pass
|
|
time.sleep(0.4)
|
|
return False
|
|
|
|
|
|
def check(label: str, ok: bool, detail: str = ""):
|
|
global exit_code
|
|
if ok:
|
|
puts(f" [+] {label}", color=colors.green)
|
|
else:
|
|
exit_code = 1
|
|
puts(f" [-] {label} {detail}", color=colors.red)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--address", "-a", type=str, required=False,
|
|
default="http://127.0.0.1:4444/", help="Address of firegex backend")
|
|
parser.add_argument("--password", "-p", type=str, required=True, help="Firegex password")
|
|
parser.add_argument("--port", "-P", type=int, required=False, default=1337,
|
|
help="Port of the test service")
|
|
parser.add_argument("--ipv6", "-6", action="store_true", default=False, help="Test IPv6")
|
|
parser.add_argument("--transport", "-t", type=str,
|
|
choices=["proxy", "nfqueue", "external"],
|
|
default="proxy", help="Which network layer to test")
|
|
parser.add_argument("--tls", action="store_true", default=False,
|
|
help="Put the service behind TLS")
|
|
args = parser.parse_args()
|
|
|
|
api = FiregexAPI(args.address)
|
|
sep()
|
|
puts(f"Services test: {args.transport} transport"
|
|
f"{' + TLS' if args.tls else ''}{' (IPv6)' if args.ipv6 else ''}", is_bold=True)
|
|
sep()
|
|
|
|
if not api.login(args.password):
|
|
puts("Login failed", color=colors.red)
|
|
exit(1)
|
|
|
|
ip = "::1" if args.ipv6 else "127.0.0.1"
|
|
name = f"test-{secrets.token_hex(4)}"
|
|
cert = key = None
|
|
if args.tls:
|
|
cert, key = generate_self_signed_cert_key(ip)
|
|
|
|
handing_off = args.transport == "external"
|
|
|
|
server = TcpServer(args.port, args.ipv6, tls_cert=cert if args.tls else None,
|
|
tls_key=key if args.tls else None)
|
|
server.start()
|
|
|
|
# The stand-in for a proxy the operator wrote themselves: it listens on a port of
|
|
# its own while its client dials the service's, so the rules are what has to put it
|
|
# in the path. It answers with a marker rather than echoing, because the real
|
|
# service echoes too and two echoes cannot be told apart.
|
|
external = TcpServer(args.port + 1, args.ipv6, proxy_port=args.port) if handing_off else None
|
|
if external:
|
|
external.start()
|
|
|
|
service_id = None
|
|
try:
|
|
# --- the network layer ------------------------------------------------
|
|
service_id = api.services_add(
|
|
name, ip, args.port, args.transport, tls=args.tls, tls_cert=cert, tls_key=key,
|
|
proxy_ip=ip if external else None,
|
|
proxy_port=(args.port + 1) if external else None,
|
|
)
|
|
check("create a service", service_id is not None)
|
|
if service_id is None:
|
|
exit(1)
|
|
|
|
listed = [s for s in api.services_list() if s["service_id"] == service_id]
|
|
check("it shows up with its transport",
|
|
len(listed) == 1 and listed[0]["transport"] == args.transport, str(listed))
|
|
|
|
if handing_off:
|
|
# Nothing of firegex is in the path here, so the whole point is that traffic
|
|
# aimed at the service arrives at the operator's own proxy instead.
|
|
check("start the hand-off", api.services_start(service_id))
|
|
time.sleep(1)
|
|
check("traffic aimed at the service reaches your proxy",
|
|
reaches_proxy(external), "it reached the real service instead")
|
|
|
|
# A filter here would be one that never runs, so it is refused at the moment
|
|
# it is attached rather than accepted and broken later.
|
|
why = api.services_add_filter_error(service_id, "regex", "patterns")
|
|
check("attaching a filter to a running hand-off is refused, naming the reason",
|
|
why is not None and "would never run" in why, str(why))
|
|
|
|
# Stopped, it is only configuration, so it is stored — and then starting is
|
|
# what refuses, with the same reason.
|
|
api.services_stop(service_id)
|
|
check("it can be attached while stopped",
|
|
api.services_add_filter(service_id, "regex", "patterns"))
|
|
fid = api.services_filters(service_id)[0]["filter_id"]
|
|
why = api.services_start_error(service_id)
|
|
check("starting with a filter is refused, naming the reason",
|
|
why is not None and "would never run" in why, str(why))
|
|
check("removing it lets the hand-off start again",
|
|
api.services_delete_filter(service_id, fid))
|
|
check("and it starts", api.services_start(service_id))
|
|
time.sleep(0.8)
|
|
check("the hand-off still works", reaches_proxy(external))
|
|
|
|
entries = api.services_logs(service_id)
|
|
check("the log records the hand-off", any(
|
|
"started on the external layer" in e["text"] for e in entries),
|
|
str(entries[-3:]))
|
|
raise SystemExit # the rest of this suite is about filters, which this has none of
|
|
|
|
# --- the filter chain -------------------------------------------------
|
|
check("attach a regex filter", api.services_add_filter(service_id, "regex", "patterns"))
|
|
chain = api.services_filters(service_id)
|
|
check("the chain has one link", len(chain) == 1, str(chain))
|
|
regex_filter = chain[0]["filter_id"]
|
|
|
|
check("add a pattern",
|
|
api.services_add_regex(service_id, regex_filter, "BLOCKME", mode="B"))
|
|
|
|
# The engine that will run the pattern is the one that validates it, so a
|
|
# pattern it cannot compile is refused here rather than at start time.
|
|
why = api.services_add_regex_error(service_id, regex_filter, "(unclosed")
|
|
check("a broken pattern is refused with the engine's own reason",
|
|
why is not None and "parenthesis" in why.lower(), str(why))
|
|
|
|
# --- the tester, which must agree with the engine ----------------------
|
|
result = api.services_debug_regex(
|
|
[{"id": "a", "expr": r"FLAG\{[a-z0-9]+\}", "case_sensitive": True},
|
|
{"id": "b", "expr": "(broken", "case_sensitive": True}],
|
|
b"give me FLAG{abc123} please")
|
|
hits = {(m["id"], m["start"], m["end"]) for m in result.get("matches", [])}
|
|
check("the tester finds the match where it is", ("a", 8, 20) in hits, str(hits))
|
|
check("the tester names the pattern that will not compile",
|
|
[e["id"] for e in result.get("errors", [])] == ["b"], str(result.get("errors")))
|
|
|
|
# --- a mixed chain, on whichever layer -------------------------------
|
|
# Both layers host an ordered chain of both kinds. They get there differently —
|
|
# the proxy walks a list inside one process, NFQUEUE chains one process per
|
|
# filter by base-chain priority — and the point of testing both identically is
|
|
# that the difference does not reach the operator.
|
|
check("attach a pyfilter", api.services_add_filter(service_id, "pyfilter", "python"))
|
|
chain = api.services_filters(service_id)
|
|
py_filter = [f["filter_id"] for f in chain if f["kind"] == "pyfilter"][0]
|
|
check("store code written against the documented API",
|
|
api.services_set_code(service_id, py_filter, PYFILTER_CODE))
|
|
check("read the code back verbatim",
|
|
api.services_get_code(service_id, py_filter) == PYFILTER_CODE)
|
|
|
|
check("reorder the chain",
|
|
api.services_reorder_filters(service_id, [py_filter, regex_filter]))
|
|
chain = api.services_filters(service_id)
|
|
check("the new order stuck",
|
|
[f["filter_id"] for f in chain] == [py_filter, regex_filter], str(chain))
|
|
check("reordering back",
|
|
api.services_reorder_filters(service_id, [regex_filter, py_filter]))
|
|
|
|
# --- traffic ----------------------------------------------------------
|
|
check("start the service", api.services_start(service_id))
|
|
time.sleep(1)
|
|
|
|
if args.tls:
|
|
# The service's own address, exactly as a client outside would dial it.
|
|
# There is no second port to be pointed at any more: the engine decrypts at
|
|
# the address the world connects to, so a TLS service occupies nothing that a
|
|
# plain one does not.
|
|
address = api.services_addresses(service_id)[0]
|
|
ssl_port = args.port
|
|
check("a TLS service reports no port of its own",
|
|
"ssl_port" not in address and "clear_port" not in address, str(address))
|
|
got = tls_connect_send_recv(ssl_port, args.ipv6, b"harmless traffic")
|
|
check("benign traffic gets through", got == b"harmless traffic", repr(got))
|
|
got = tls_connect_send_recv(ssl_port, args.ipv6, b"carrying BLOCKME")
|
|
check("matching traffic is blocked", not got or b"BLOCKME" not in got, repr(got))
|
|
|
|
# --- the decrypted traffic has an interface of its own ------------------
|
|
# The engine decrypts inside the process, so the plaintext is never a packet
|
|
# on any interface — which is what removed the two loopback ports, and would
|
|
# have removed any way to watch it. So the engine writes the stream out
|
|
# itself, onto one device carrying every TLS service's plaintext and nothing
|
|
# else. They are reconstructed packets: what crossed the wire was encrypted.
|
|
if args.tls and capture_device_present():
|
|
plaintext = capture_on("firegex0", 4.0, lambda: (
|
|
tls_connect_send_recv(ssl_port, args.ipv6, b"harmless traffic")))
|
|
check("the capture interface carries the plaintext",
|
|
b"harmless traffic" in plaintext, repr(plaintext[-200:]))
|
|
|
|
|
|
else:
|
|
check("benign traffic gets through", gets_through(server, b"harmless traffic"))
|
|
check("matching traffic is blocked", not server.sendCheckData(b"carrying BLOCKME"))
|
|
|
|
# The reason the matcher keeps per-connection state: a pattern that lands
|
|
# across two writes is still the pattern.
|
|
server.connect_client()
|
|
server.send_packet(b"split BLOC")
|
|
time.sleep(0.3)
|
|
server.send_packet(b"KME here")
|
|
check("a pattern split across two writes is still caught",
|
|
server.recv_packet() != b"KME here")
|
|
server.close_client()
|
|
|
|
check("the pyfilter in the chain blocks too",
|
|
not server.sendCheckData(b"carrying PYBLOCK"))
|
|
|
|
|
|
# The block is attributed to the rule that fired, not to the service.
|
|
time.sleep(0.5)
|
|
patterns = api.services_regexes(service_id, regex_filter)
|
|
check("the block is counted against the pattern",
|
|
any(p["blocked"] >= 1 for p in patterns), str(patterns))
|
|
|
|
# --- editing a pattern in place ---------------------------------------
|
|
# A typo in a rule is found while it is running, and retyping it as a new rule
|
|
# loses its place in the chain. The row stays; what it matches changes.
|
|
blockme = [p for p in patterns if base64.b64decode(p["regex"]) == b"BLOCKME"][0]
|
|
check("the pattern had blocked something before the edit", blockme["blocked"] >= 1)
|
|
|
|
check("a pattern is refused if it does not compile",
|
|
api.services_edit_regex_error(
|
|
service_id, regex_filter, blockme["regex_id"],
|
|
regex=base64.b64encode(b"(unclosed").decode()) is not None)
|
|
still = [p for p in api.services_regexes(service_id, regex_filter)
|
|
if p["regex_id"] == blockme["regex_id"]][0]
|
|
check("and the refused edit left the rule exactly as it was",
|
|
still["regex"] == blockme["regex"] and still["blocked"] == blockme["blocked"],
|
|
str(still))
|
|
|
|
before = api.services_stats(service_id)
|
|
check("edit the pattern in place",
|
|
api.services_edit_regex(service_id, regex_filter, blockme["regex_id"],
|
|
regex=base64.b64encode(b"STOPME").decode()))
|
|
edited = [p for p in api.services_regexes(service_id, regex_filter)
|
|
if p["regex_id"] == blockme["regex_id"]][0]
|
|
check("the rule kept its identity", edited["regex_id"] == blockme["regex_id"])
|
|
# The counts were about the text that used to be there. Carrying them over would
|
|
# credit blocks to a matcher that never made them.
|
|
check("and lost the counters that belonged to the old pattern",
|
|
edited["blocked"] == 0, str(edited))
|
|
|
|
# What must *not* happen is the chart losing them. Those connections were
|
|
# refused, in those minutes, by this filter; a later typo fix has nothing to say
|
|
# about that, and deleting the rows took a bite out of the timeline — on a filter
|
|
# holding one pattern, it emptied it. They are handed to the filter instead.
|
|
after = api.services_stats(service_id)
|
|
check("the timeline keeps what the old pattern refused",
|
|
sum(sum(sr["counts"]) for sr in after["series"])
|
|
== sum(sum(sr["counts"]) for sr in before["series"]),
|
|
f"{before['series']} -> {after['series']}")
|
|
check("and so does the total, and the filter it belongs to",
|
|
after["total"] == before["total"]
|
|
and [f["blocked"] for f in after["filters"]] == [f["blocked"] for f in before["filters"]],
|
|
f"{before['total']}/{[f['blocked'] for f in before['filters']]} -> "
|
|
f"{after['total']}/{[f['blocked'] for f in after['filters']]}")
|
|
# And the breakdown still adds up: those blocks belong to no pattern now, so
|
|
# they are named rather than silently missing from a list that should sum to the
|
|
# chart above it.
|
|
check("the per-pattern breakdown names what no rule accounts for any more",
|
|
any(row.get("residual") for row in after["patterns"]), str(after["patterns"]))
|
|
check("so the shares still add up",
|
|
sum(row["blocked"] for row in after["patterns"] + after["functions"])
|
|
== after["total"],
|
|
f"{after['patterns']} + {after['functions']} vs {after['total']}")
|
|
|
|
# The edit reaches the datapath, not only the table.
|
|
time.sleep(1)
|
|
if args.tls:
|
|
got = tls_connect_send_recv(ssl_port, args.ipv6, b"carrying STOPME")
|
|
check("the new pattern is the one the datapath enforces",
|
|
not got or b"STOPME" not in got, repr(got))
|
|
else:
|
|
check("the new pattern is the one the datapath enforces",
|
|
not server.sendCheckData(b"carrying STOPME"))
|
|
check("and the old one no longer blocks anything",
|
|
gets_through(server, b"carrying BLOCKME"))
|
|
|
|
# Two identical rules in one filter are one rule twice: the second can never be
|
|
# the reason for anything, and the list would say otherwise.
|
|
check("add a second pattern",
|
|
api.services_add_regex(service_id, regex_filter, "OTHERONE"))
|
|
other = [p for p in api.services_regexes(service_id, regex_filter)
|
|
if base64.b64decode(p["regex"]) == b"OTHERONE"][0]
|
|
check("editing one onto another is refused",
|
|
api.services_edit_regex_error(
|
|
service_id, regex_filter, other["regex_id"],
|
|
regex=base64.b64encode(b"STOPME").decode()) is not None)
|
|
check("remove the second pattern",
|
|
api.services_delete_regex(service_id, regex_filter, other["regex_id"]))
|
|
|
|
# Put it back, so everything after this reads the same as before.
|
|
check("edit it back",
|
|
api.services_edit_regex(service_id, regex_filter, blockme["regex_id"],
|
|
regex=base64.b64encode(b"BLOCKME").decode()))
|
|
# Blocked again, on both paths — and deliberately so, not only as a check: the
|
|
# edit reset this rule's counters, and everything below reads the statistics per
|
|
# pattern. Leaving it at zero would fail those tests for a reason that has
|
|
# nothing to do with what they are about.
|
|
time.sleep(1)
|
|
if args.tls:
|
|
got = tls_connect_send_recv(ssl_port, args.ipv6, b"carrying BLOCKME")
|
|
check("and the original pattern blocks again",
|
|
not got or b"BLOCKME" not in got, repr(got))
|
|
else:
|
|
check("and the original pattern blocks again",
|
|
not server.sendCheckData(b"carrying BLOCKME"))
|
|
time.sleep(0.5)
|
|
|
|
# --- what the editor is allowed to suggest ----------------------------
|
|
# Introspected from the library, so a model that gains a property gains a hint.
|
|
# Checked against the library's own table rather than a list written here: a
|
|
# second list is the thing this endpoint exists to avoid.
|
|
described = api.services_pyfilter_api()
|
|
names = {m["name"] for m in described["models"]}
|
|
check("the library describes its own models",
|
|
{"RawPacket", "HttpRequest", "TCPInputStream"} <= names, str(sorted(names)))
|
|
raw = [m for m in described["models"] if m["name"] == "RawPacket"][0]
|
|
members = {m["name"]: m for m in raw["members"]}
|
|
check("with the metadata a filter may read",
|
|
{"client_ip", "server_port", "is_tcp", "is_input"} <= set(members), str(sorted(members)))
|
|
check("and only the payload marked writable",
|
|
members["data"]["writable"] and not members["client_ip"]["writable"],
|
|
str([(k, v["writable"]) for k, v in members.items() if v["writable"]]))
|
|
check("HttpRequest belongs to http alone, RawPacket to everything",
|
|
[m for m in described["models"] if m["name"] == "HttpRequest"][0]["protocols"] == ["http"]
|
|
and len(raw["protocols"]) > 1, str(raw["protocols"]))
|
|
check("the verdicts are described too",
|
|
{v["name"] for v in described["verdicts"]}
|
|
== {"ACCEPT", "REJECT", "DROP"}, str(described["verdicts"]))
|
|
|
|
# --- what has been refused, and when ----------------------------------
|
|
# The totals say which rule is doing the work; the timeline says when it
|
|
# started, which a cumulative counter cannot.
|
|
stats = api.services_stats(service_id)
|
|
check("the stats name every filter in the chain",
|
|
{f["id"] for f in stats["filters"]} == {f["filter_id"] for f in chain},
|
|
str(stats["filters"]))
|
|
check("the totals agree with the per-pattern counters",
|
|
stats["total"] >= 1 and any(p["blocked"] >= 1 for p in stats["patterns"]),
|
|
str(stats["total"]))
|
|
# Every step is drawn whether or not anything landed in it — a chart with the
|
|
# quiet minutes missing lies about the shape of a burst — and every series is as
|
|
# long as the axis. The service is seconds old here, so the window it is given
|
|
# is short by design; what has to hold is that the two agree.
|
|
check("every bucket is present, including the quiet ones",
|
|
len(stats["buckets"]) >= 1
|
|
and all(len(sr["counts"]) == len(stats["buckets"]) for sr in stats["series"])
|
|
and all(b - a == stats["bucket_seconds"]
|
|
for a, b in zip(stats["buckets"], stats["buckets"][1:])),
|
|
str(stats["buckets"]))
|
|
check("the timeline attributes the blocks to a filter",
|
|
sum(sum(sr["counts"]) for sr in stats["series"]) >= 1, str(stats["series"]))
|
|
|
|
# --- the range is one question, asked of everything ---------------------
|
|
# Chart, totals and shares all count the window that was asked for. A page where
|
|
# the chart honours a range and the table beside it reports all of time is a page
|
|
# that contradicts itself.
|
|
import time as _time
|
|
now = int(_time.time())
|
|
recent = api.services_stats(service_id, range_from=now - 900, range_to=now)
|
|
check("a narrow range still sees what just happened",
|
|
recent["total"] >= 1 and recent["range_to"] - recent["range_from"] <= 900,
|
|
str((recent["total"], recent["range_from"], recent["range_to"])))
|
|
check("its filters, patterns and functions count that same range",
|
|
sum(f["blocked"] for f in recent["filters"]) == recent["total"],
|
|
str([(f["name"], f["blocked"]) for f in recent["filters"]]))
|
|
check("and the lifetime figure is reported beside it, not instead of it",
|
|
recent["all_time"] >= recent["total"], str((recent["all_time"], recent["total"])))
|
|
|
|
old = api.services_stats(service_id, range_from=now - 7200, range_to=now - 3600)
|
|
check("a range that ended before any of it sees nothing",
|
|
old["total"] == 0 and all(f["blocked"] == 0 for f in old["filters"]), str(old["total"]))
|
|
|
|
# A wider window is the same rows in wider steps, never more bars than asked for.
|
|
wide = api.services_stats(service_id, range_from=now - 24 * 3600, range_to=now, buckets=20)
|
|
check("a wide range never multiplies the bars",
|
|
wide["bucket_seconds"] >= recent["bucket_seconds"] and len(wide["buckets"]) <= 24,
|
|
str((wide["bucket_seconds"], len(wide["buckets"]))))
|
|
check("and asking beyond what is kept is clamped, saying how far back that is",
|
|
api.services_stats(service_id, range_from=0, range_to=now)["range_from"]
|
|
>= wide["kept_from"], str(wide["kept_from"]))
|
|
|
|
# The step is the operator's to pick: five minutes is five minutes whether they
|
|
# are looking at an hour or at a day, which is what makes two ranges comparable
|
|
# by eye. Asked for in seconds, answered in whole stored buckets.
|
|
stepped = api.services_stats(service_id, range_from=now - 6 * 3600,
|
|
range_to=now, step=300)
|
|
check("the step can be asked for instead of derived",
|
|
stepped["bucket_seconds"] == 300, str(stepped["bucket_seconds"]))
|
|
check("and it regroups the same rows rather than fetching different ones",
|
|
sum(sum(sr["counts"]) for sr in stepped["series"]) == stepped["total"],
|
|
str((stepped["total"], stepped["series"])))
|
|
# Never refused, only widened: the request is reasonable, the chart it would draw
|
|
# is not, and the answer says which step it actually used.
|
|
melting = api.services_stats(service_id, range_from=now - 48 * 3600,
|
|
range_to=now, step=1)
|
|
check("a step finer than the history is widened to one whole bucket",
|
|
melting["bucket_seconds"] >= 60 and len(melting["buckets"]) <= 400,
|
|
str((melting["bucket_seconds"], len(melting["buckets"]))))
|
|
|
|
# The hours before a service was running with a filter are not quiet hours, and
|
|
# a chart of them reads as "nothing is happening" when it means "this did not
|
|
# exist yet". Every range is floored there, exactly as it is floored at what is
|
|
# still kept, and the range actually served says so.
|
|
began = wide["filtering_since"]
|
|
check("the service knows when it began filtering",
|
|
began is not None and began <= now, str(began))
|
|
check("and no range starts before that, however far back it asks",
|
|
wide["range_from"] == began
|
|
and api.services_stats(service_id, range_from=0, range_to=now)["range_from"] == began,
|
|
str((wide["range_from"], began)))
|
|
check("a window that ended before it began is answered as asked, and is empty",
|
|
old["range_to"] <= began and old["total"] == 0
|
|
and old["range_from"] < old["range_to"],
|
|
str((old["range_from"], old["range_to"], began, old["total"])))
|
|
|
|
# Which rule is doing the work, not just how much was refused. Raw counts hide a
|
|
# chain where one pattern accounts for nearly everything.
|
|
shares = [f["share"] for f in stats["filters"] if f["blocked"] > 0]
|
|
check("every rule reports its share of the blocking",
|
|
shares and abs(sum(f["share"] for f in stats["filters"]) - 100) < 1.5,
|
|
str([(f["name"], f["blocked"], f["share"]) for f in stats["filters"]]))
|
|
check("the patterns are broken down the same way",
|
|
all("share" in p for p in stats["patterns"]), str(stats["patterns"][:2]))
|
|
check("and the @pyfilter functions have a breakdown of their own",
|
|
"functions" in stats, str(list(stats)))
|
|
|
|
# How much of what arrived any of it amounts to. Packets come from the kernel's
|
|
# own counters, so a filter cannot skew them; connections come from the proxy
|
|
# engine, and only there is a refused share a division of like by like.
|
|
# The engine reports its counters on a timer, so this is a number that arrives
|
|
# rather than one that is there. Waiting for it is the test being correct about
|
|
# the contract, not being lenient about it.
|
|
traffic = stats["traffic"]
|
|
if args.transport == "proxy":
|
|
for _ in range(20):
|
|
if traffic.get("connections"):
|
|
break
|
|
time.sleep(0.5)
|
|
traffic = api.services_stats(service_id)["traffic"]
|
|
if args.transport != "proxy":
|
|
check("the kernel's own counters say how much reached the service",
|
|
traffic["packets"] > 0 and traffic["bytes"] > 0, str(traffic))
|
|
else:
|
|
# Its rule lives in a nat chain, and conntrack walks that once per
|
|
# connection: the counter there would be new connections wearing a packet
|
|
# label. The engine reports connections properly instead.
|
|
check("the proxy layer reports no packet count rather than a wrong one",
|
|
traffic["packets"] == 0, str(traffic))
|
|
if args.transport == "proxy":
|
|
check("the proxy layer counts connections, so the refused share is exact",
|
|
traffic["connections"] is not None
|
|
and traffic["refused_share"] is not None
|
|
and 0 < traffic["refused_share"] <= 100, str(traffic))
|
|
else:
|
|
# Not a hole in the reporting: this layer inspects packets and has no
|
|
# connection to take a share of. Inventing one would mean dividing refused
|
|
# connections by a packet count.
|
|
check("the per-packet layer reports no connection share rather than a made-up one",
|
|
traffic["connections"] is None and traffic["refused_share"] is None,
|
|
str(traffic))
|
|
|
|
# --- a second address, on the running service -------------------------
|
|
# One service, one chain, more than one place it answers. The datapath is
|
|
# already up, so this only points another address at it.
|
|
addresses = api.services_addresses(service_id)
|
|
check("the service reports where it is reachable",
|
|
len(addresses) == 1 and addresses[0]["port"] == args.port, str(addresses))
|
|
why = api.services_delete_address_error(service_id, addresses[0]["address_id"])
|
|
check("its only address cannot be removed, saying why",
|
|
why is not None and "only address" in why, str(why))
|
|
|
|
# Behind TLS it has to speak TLS too: the engine re-encrypts towards whatever it
|
|
# is protecting, on the new address exactly as on the first.
|
|
second = TcpServer(args.port + 2, args.ipv6,
|
|
tls_cert=cert if args.tls else None,
|
|
tls_key=key if args.tls else None)
|
|
second.start()
|
|
try:
|
|
why = api.services_add_address_error(service_id, ip, args.port + 2)
|
|
check("protect a second address without restarting", why is None, str(why))
|
|
check("both addresses are listed",
|
|
len(api.services_addresses(service_id)) == 2)
|
|
time.sleep(1)
|
|
if not args.tls:
|
|
check("benign traffic to the new address gets through",
|
|
gets_through(second, b"harmless traffic"))
|
|
# The same chain, on an address that did not exist when it started.
|
|
check("the same chain filters the new address",
|
|
not second.sendCheckData(b"carrying BLOCKME"))
|
|
else:
|
|
# The same certificate and the same chain, on an address that did not
|
|
# exist when the engine started. Nothing had to be arranged for it: there
|
|
# is no per-address terminator any more, so adding one costs what adding
|
|
# one costs without TLS.
|
|
got = tls_connect_send_recv(args.port + 2, args.ipv6, b"harmless traffic")
|
|
check("the new address is decrypted too",
|
|
got == b"harmless traffic", repr(got))
|
|
got = tls_connect_send_recv(args.port + 2, args.ipv6, b"carrying BLOCKME")
|
|
check("and the same chain filters it",
|
|
not got or b"BLOCKME" not in got, repr(got))
|
|
|
|
# The service kept running throughout, which is the point of an address
|
|
# having its own endpoints rather than being part of the service form.
|
|
entries = api.services_logs(service_id)
|
|
check("the log says the address was added without dropping anything",
|
|
any("also protecting" in e["text"] for e in entries), str(entries[-4:]))
|
|
|
|
gone = [a for a in api.services_addresses(service_id)
|
|
if a["port"] == args.port + 2][0]["address_id"]
|
|
check("remove it again", api.services_delete_address(service_id, gone))
|
|
time.sleep(0.8)
|
|
if not args.tls:
|
|
check("the removed address is no longer filtered",
|
|
gets_through(second, b"carrying BLOCKME"))
|
|
check("the original address is still protected",
|
|
args.tls or not server.sendCheckData(b"carrying BLOCKME"))
|
|
finally:
|
|
second.stop()
|
|
|
|
# --- a filter whose protocol is read off its own code -----------------
|
|
# The file asks for an HttpRequest, which is what makes it an HTTP filter. The
|
|
# service is plain TCP: the two are different questions, and confusing them is
|
|
# what used to make this a 500.
|
|
check("attach a second pyfilter", api.services_add_filter(service_id, "pyfilter", "http"))
|
|
http_filter = [f["filter_id"] for f in api.services_filters(service_id)
|
|
if f["name"] == "http"][0]
|
|
# --- the code is checked before it is accepted -----------------------
|
|
# By the process that will run it, so what is accepted here is what loads there.
|
|
# The point of the position is that the operator is not told "worker exited" and
|
|
# left to find the line in a traceback in the service log.
|
|
broken = api.services_check_code(service_id, http_filter,
|
|
"from firegex.pyfilters import pyfilter\n"
|
|
"\n"
|
|
"@pyfilter\n"
|
|
"def no_annotation(packet):\n"
|
|
" return None\n")
|
|
check("code that cannot load is refused with the line it fails on",
|
|
broken["ok"] is False and broken["error"]["line"] == 4
|
|
and "annotation" in broken["error"]["message"], str(broken))
|
|
syntax = api.services_check_code(service_id, http_filter, "def broken(:\n pass\n")
|
|
check("a syntax error is reported where it is",
|
|
syntax["ok"] is False and syntax["error"]["type"] == "SyntaxError"
|
|
and syntax["error"]["line"] == 1, str(syntax))
|
|
why = api.services_set_code_error(service_id, http_filter, "def broken(:\n")
|
|
check("and saving it is refused, saying so rather than 'worker exited'",
|
|
why is not None and "SyntaxError" in why and "line 1" in why, str(why))
|
|
|
|
good = api.services_check_code(service_id, http_filter, HTTP_PYFILTER_CODE)
|
|
check("code that loads says what it defines and what it speaks",
|
|
good["ok"] is True and good["proto"] == "http"
|
|
and set(good["filters"]) == {"refuse_traversal", "look_at_the_bytes"},
|
|
str(good))
|
|
|
|
check("code asking for parsed HTTP is accepted on a TCP service",
|
|
api.services_set_code(service_id, http_filter, HTTP_PYFILTER_CODE))
|
|
detected = [f for f in api.services_filters(service_id)
|
|
if f["filter_id"] == http_filter][0]
|
|
check("and its protocol was read off the code, not chosen",
|
|
detected["proto"] == "http", str(detected))
|
|
time.sleep(1)
|
|
if not args.tls:
|
|
check("an HTTP request it objects to is refused",
|
|
not server.sendCheckData(
|
|
b"GET /../../etc/passwd HTTP/1.1\r\nHost: x\r\n\r\n"))
|
|
check("an HTTP request it does not object to gets through",
|
|
gets_through(server, b"GET /shop HTTP/1.1\r\nHost: x\r\n\r\n"))
|
|
|
|
# --- the functions inside that file, one at a time --------------------
|
|
# A file holds several @pyfilter functions. The code says which exist; the
|
|
# operator says which run. Switching one off must stop it deciding, without the
|
|
# code being edited — deleting a function to silence it and pasting it back to
|
|
# resume is exactly what these switches replace.
|
|
fns = api.services_functions(service_id, http_filter)
|
|
check("every function the file defines is listed",
|
|
{f["name"] for f in fns} == {"refuse_traversal", "look_at_the_bytes"}, str(fns))
|
|
check("and they arrive switched on", all(f["active"] for f in fns), str(fns))
|
|
# In the order the file defines them, which is the order they run in. The list
|
|
# used to be sorted by name, over a run order that was a set's iteration order,
|
|
# so neither the operator nor the file could say which function saw a chunk
|
|
# first — and the first one to refuse is the one that ends it.
|
|
defined_order = [
|
|
line.split("def ", 1)[1].split("(", 1)[0]
|
|
for line in HTTP_PYFILTER_CODE.splitlines() if line.startswith("def ")
|
|
]
|
|
check("listed in the order the file defines them",
|
|
[f["name"] for f in fns] == defined_order,
|
|
str(([f["name"] for f in fns], defined_order)))
|
|
if not args.tls:
|
|
check("the block was counted against the function that made it",
|
|
any(f["name"] == "refuse_traversal" and f["blocked"] >= 1 for f in fns),
|
|
str(fns))
|
|
|
|
check("switch one function off",
|
|
api.services_edit_function(service_id, http_filter, "refuse_traversal", False))
|
|
listed = api.services_filters(service_id)
|
|
card = [f for f in listed if f["filter_id"] == http_filter][0]
|
|
check("the chain reports how many are left on",
|
|
card["n_functions"] == 2 and card["n_functions_active"] == 1, str(card))
|
|
time.sleep(1)
|
|
if not args.tls:
|
|
# The other function is still in the file and still running; this one is
|
|
# simply no longer consulted.
|
|
check("the switched-off function no longer refuses anything",
|
|
gets_through(server,
|
|
b"GET /../../etc/passwd HTTP/1.1\r\nHost: x\r\n\r\n"))
|
|
|
|
check("switch it back on",
|
|
api.services_edit_function(service_id, http_filter, "refuse_traversal", True))
|
|
time.sleep(1)
|
|
if not args.tls:
|
|
check("and it decides again",
|
|
not server.sendCheckData(
|
|
b"GET /../../etc/passwd HTTP/1.1\r\nHost: x\r\n\r\n"))
|
|
|
|
# The code is what decides which functions exist, so replacing it drops the ones
|
|
# that have gone — while what the operator set for the survivors stays set.
|
|
api.services_edit_function(service_id, http_filter, "look_at_the_bytes", False)
|
|
check("saving different code reconciles the list",
|
|
api.services_set_code(service_id, http_filter, PYFILTER_CODE))
|
|
fns = api.services_functions(service_id, http_filter)
|
|
check("the functions that are gone from the code are gone from the list",
|
|
{f["name"] for f in fns} == {"refuse_marker"}, str(fns))
|
|
|
|
check("remove it again", api.services_delete_filter(service_id, http_filter))
|
|
|
|
# --- the live log -----------------------------------------------------
|
|
# It has to name what refused the connection. An operator reading an opaque id
|
|
# mid-round learns nothing, which is the same as having no log.
|
|
entries = api.services_logs(service_id)
|
|
check("the log records the start", any(
|
|
e["level"] == "info" and "started on the" in e["text"] for e in entries), str(entries[:3]))
|
|
blocks = [e for e in entries if e["level"] == "block"]
|
|
check("the log names what refused the connection",
|
|
any("BLOCKME" in e["text"] for e in blocks), str(blocks[:3]))
|
|
check("the log is bounded", len(entries) <= 500, str(len(entries)))
|
|
|
|
# --- editing under traffic --------------------------------------------
|
|
check("add a pattern to a running service",
|
|
api.services_add_regex(service_id, regex_filter, "SECOND", mode="B"))
|
|
time.sleep(0.5)
|
|
if not args.tls:
|
|
check("the new pattern is already in force",
|
|
not server.sendCheckData(b"carrying SECOND"))
|
|
|
|
before = api.services_logs(service_id)
|
|
check("clearing the log", api.services_clear_logs(service_id))
|
|
# Not "empty": the service is still running, and a line arriving a millisecond
|
|
# later belongs in the log. What has to be gone is everything from before.
|
|
after = api.services_logs(service_id)
|
|
oldest_kept = min((e["seq"] for e in after), default=None)
|
|
check("clearing drops what came before",
|
|
len(after) < len(before)
|
|
and (oldest_kept is None or oldest_kept > max(e["seq"] for e in before)),
|
|
f"{len(before)} -> {len(after)}")
|
|
|
|
|
|
|
|
# --- ALPN is the service's answer, carried, not the proxy's ------------
|
|
# Terminating in the middle of a connection means answering for a service, and
|
|
# answering something the service did not say is how a proxy breaks a protocol it
|
|
# was only supposed to carry: a client told `h2` while the service speaks
|
|
# HTTP/1.1 sends frames to something that cannot read them. So the ClientHello is
|
|
# held open, the service is asked with exactly the client's list, and the client
|
|
# is told exactly what came back — including "nothing".
|
|
if args.tls:
|
|
for offers, speaks, expected, why in (
|
|
(["h2", "http/1.1"], ["h2", "http/1.1"], "h2", "both ends want h2"),
|
|
(["h2", "http/1.1"], ["http/1.1"], "http/1.1",
|
|
"the service does not speak h2, so neither does the client"),
|
|
(["h2"], ["http/1.1"], None,
|
|
"nobody agrees, and the proxy does not invent an agreement"),
|
|
(["h2", "http/1.1"], None, None, "the service offers no ALPN at all"),
|
|
):
|
|
alpn_port = args.port + 13
|
|
alpn_server = TcpServer(alpn_port, args.ipv6, tls_cert=cert, tls_key=key,
|
|
tls_alpn=speaks)
|
|
alpn_server.start()
|
|
alpn_id = api.services_add(f"{name}-alpn", ip, alpn_port, args.transport,
|
|
tls=True, tls_cert=cert, tls_key=key)
|
|
try:
|
|
api.services_start(alpn_id)
|
|
time.sleep(1.5)
|
|
got = tls_alpn_choice(alpn_port, args.ipv6, offers)
|
|
check(f"ALPN: {why}", got == expected, f"got {got!r}, wanted {expected!r}")
|
|
finally:
|
|
if alpn_id:
|
|
api.services_stop(alpn_id)
|
|
api.services_delete(alpn_id)
|
|
alpn_server.stop()
|
|
time.sleep(0.8)
|
|
|
|
# --- a key too small for the engine is refused, and says why ------------
|
|
# nginx could be told to take an under-2048-bit RSA key with @SECLEVEL=1. rustls
|
|
# has no equivalent: its signing backend refuses such a key outright, so a
|
|
# service carrying one cannot be decrypted here at all. What matters is that the
|
|
# operator is told that, rather than being handed "the engine did not report a
|
|
# listening port" and left to find the reason in a container log.
|
|
if args.tls:
|
|
weak_port = args.port + 9
|
|
weak_cert, weak_key = generate_self_signed_cert_key(ip, key_size=1024)
|
|
weak = api.services_add(f"{name}-wk", ip, weak_port, args.transport,
|
|
tls=True, tls_cert=weak_cert, tls_key=weak_key)
|
|
check("a service with a 1024-bit certificate can be created", weak is not None)
|
|
try:
|
|
refused = api.services_start_error(weak)
|
|
check("but will not start, carrying the engine's own reason",
|
|
refused is not None and "key" in str(refused).lower(), str(refused))
|
|
finally:
|
|
if weak:
|
|
api.services_delete(weak)
|
|
time.sleep(0.5)
|
|
|
|
# --- one broken TLS service is one broken TLS service -------------------
|
|
# It used to be able to take every other one down with it: nginx parses its
|
|
# configuration as a unit and refuses to start if any one `ssl_certificate` will
|
|
# not load, so a service whose material was damaged left every TLS service on the
|
|
# instance refusing connections on ports that had been working. Each service now
|
|
# carries its certificate into its own engine process, so the blast radius is
|
|
# structural rather than something that has to be arranged — which is worth a
|
|
# test precisely because nothing in the code says so any more.
|
|
if args.tls:
|
|
good_port, bad_port = args.port + 5, args.port + 7
|
|
neighbour = TcpServer(good_port, args.ipv6, tls_cert=cert, tls_key=key)
|
|
neighbour.start()
|
|
good = api.services_add(f"{name}-ok", ip, good_port, args.transport,
|
|
tls=True, tls_cert=cert, tls_key=key)
|
|
bad = api.services_add(f"{name}-brk", ip, bad_port, args.transport)
|
|
check("a working TLS service and one to break", good and bad)
|
|
try:
|
|
refusal = api.services_edit_error(bad, proto="tls")
|
|
check("switching to TLS with nothing stored is refused",
|
|
refusal is not None, str(refusal))
|
|
# Forced past that refusal the only way left: material whose envelope is
|
|
# right and whose body will not parse. The envelope check cannot judge
|
|
# this one, so it is the engine that has to say so.
|
|
api.services_edit(bad, proto="tls",
|
|
tls_cert="-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----\n",
|
|
tls_key="-----BEGIN PRIVATE KEY-----\nnope\n-----END PRIVATE KEY-----\n")
|
|
check("the working one starts", api.services_start(good))
|
|
refused = api.services_start_error(bad)
|
|
check("the broken one refuses to start, with the engine's reason",
|
|
refused is not None
|
|
and ("certificate" in str(refused).lower() or "key" in str(refused).lower()),
|
|
str(refused))
|
|
time.sleep(1.5)
|
|
got = tls_connect_send_recv(good_port, args.ipv6, b"harmless traffic")
|
|
check("and its neighbour is still serving",
|
|
got == b"harmless traffic", repr(got))
|
|
finally:
|
|
for sid in (good, bad):
|
|
if sid:
|
|
api.services_stop(sid)
|
|
api.services_delete(sid)
|
|
neighbour.stop()
|
|
time.sleep(1)
|
|
|
|
if args.transport == "proxy":
|
|
# --- the connection limit, and the trace it leaves ----------------------
|
|
# The attack this exists for was measured before it did: connections that are
|
|
# opened and then say nothing cost two descriptors each — one from the client,
|
|
# one to the service, because the upstream is dialled on accept — and about 505
|
|
# of them exhausted the container and took *every* service down. The limit does
|
|
# not save the service being attacked, because a cap cannot tell a phantom from
|
|
# a client; it stops one service's attacker from being everyone's.
|
|
# The proxy layer only: it is the one that accepts connections and dials the
|
|
# service, which is what a limit counts. NFQUEUE hands the kernel a verdict on
|
|
# packets already in flight.
|
|
limited_port = args.port + 11
|
|
limited_server = TcpServer(limited_port, args.ipv6)
|
|
limited_server.start()
|
|
limited = api.services_add(f"{name}-cap", ip, limited_port, args.transport,
|
|
max_connections=4)
|
|
check("a service with a connection limit", limited is not None)
|
|
try:
|
|
check("starts", bool(limited) and api.services_start(limited))
|
|
time.sleep(1.5)
|
|
listed = [s for s in api.services_list() if s["service_id"] == limited][0]
|
|
check("reports its limit", listed["max_connections"] == 4, str(listed))
|
|
check("and has turned nothing away yet", listed["over_limit_hits"] == 0, str(listed))
|
|
|
|
phantoms = []
|
|
for _ in range(12):
|
|
try:
|
|
phantoms.append(socket.create_connection(
|
|
("::1" if args.ipv6 else "127.0.0.1", limited_port), timeout=3))
|
|
except OSError:
|
|
break
|
|
time.sleep(3)
|
|
listed = [s for s in api.services_list() if s["service_id"] == limited][0]
|
|
check("the limit turns away what does not fit",
|
|
listed["over_limit_hits"] > 0, str(listed["over_limit_hits"]))
|
|
# The trace, which is the point: the live log is a bounded ring, so a burst
|
|
# at three in the morning would be gone by breakfast.
|
|
check("and says when it first and last happened",
|
|
listed["over_limit_first"] is not None and listed["over_limit_last"] is not None,
|
|
str(listed))
|
|
check("the log warns about it too",
|
|
any("limit" in e["text"].lower() for e in api.services_logs(limited)),
|
|
str([e["text"] for e in api.services_logs(limited)][-3:]))
|
|
for phantom in phantoms:
|
|
phantom.close()
|
|
time.sleep(1.5)
|
|
was = listed["over_limit_hits"]
|
|
api.services_stop(limited)
|
|
api.services_start(limited)
|
|
time.sleep(1.5)
|
|
listed = [s for s in api.services_list() if s["service_id"] == limited][0]
|
|
check("and the count outlives the engine that made it",
|
|
listed["over_limit_hits"] >= was,
|
|
f'{was} -> {listed["over_limit_hits"]}')
|
|
# --- and what the limit alone cannot do -------------------------
|
|
# A cap contains the damage; it cannot tell a connection that is silent
|
|
# because it is an attack from one that is silent because the client is
|
|
# slow, so an attacker who fills it keeps it filled. A deadline on the
|
|
# *first* byte can: the slots come back while the attacker is still
|
|
# holding the sockets.
|
|
api.services_edit(limited, first_byte_timeout=3)
|
|
time.sleep(2)
|
|
held = []
|
|
for _ in range(8):
|
|
try:
|
|
held.append(socket.create_connection(
|
|
("::1" if args.ipv6 else "127.0.0.1", limited_port), timeout=3))
|
|
except OSError:
|
|
break
|
|
time.sleep(1)
|
|
check("with the limit full, a client cannot get in",
|
|
not gets_through(limited_server, b"knock knock"))
|
|
time.sleep(5)
|
|
check("but the deadline frees the slots while the attacker still holds them",
|
|
gets_through(limited_server, b"knock knock"))
|
|
for phantom in held:
|
|
phantom.close()
|
|
finally:
|
|
if limited:
|
|
api.services_stop(limited)
|
|
api.services_delete(limited)
|
|
limited_server.stop()
|
|
time.sleep(1)
|
|
|
|
# --- UDP, on whichever layer this run is exercising ---------------------
|
|
# Both carry it, and the same filters work on both — which is the whole point of
|
|
# the model. They get there differently: the proxy relays datagrams with one
|
|
# socket per address (the kernel option that recovers a TCP connection's original
|
|
# destination is TCP-only, so there is nothing to recover per datagram), while
|
|
# nfqueue inspects the real datagram in place. What the proxy gives up is the
|
|
# client's address; what both keep is exact rewriting, because a datagram has no
|
|
# sequence numbers to desynchronise.
|
|
if args.transport in ("proxy", "nfqueue") and not args.tls:
|
|
udp_port = args.port + 3
|
|
echo = UdpEcho(udp_port, args.ipv6)
|
|
echo.start()
|
|
udp_id = api.services_add(f"{name}-udp", ip, udp_port, args.transport, proto="udp")
|
|
check(f"a UDP service on the {args.transport} layer can be created",
|
|
udp_id is not None)
|
|
try:
|
|
api.services_add_filter(udp_id, "regex", "patterns")
|
|
ufid = api.services_filters(udp_id)[0]["filter_id"]
|
|
api.services_add_regex(udp_id, ufid, "DENYME", mode="B")
|
|
check("and it starts", api.services_start(udp_id))
|
|
time.sleep(1.2)
|
|
|
|
check("a benign datagram is relayed and answered",
|
|
echo.exchange(b"hello there") == b"hello there")
|
|
check("a matching datagram is refused", echo.exchange(b"carrying DENYME") is None)
|
|
# Nothing was closed — UDP has no connection to close — so the same flow
|
|
# keeps working for the datagrams that are not refused.
|
|
check("the flow survives a refused datagram",
|
|
echo.exchange(b"still here") == b"still here")
|
|
|
|
time.sleep(0.5)
|
|
rows = api.services_regexes(udp_id, ufid)
|
|
check("the block is counted against the pattern",
|
|
any(r["blocked"] >= 1 for r in rows), str(rows))
|
|
|
|
# --- a Python filter on UDP ----------------------------------
|
|
# It works on datagrams, and the models that need a stream underneath
|
|
# are refused rather than accepted and never called.
|
|
api.services_stop(udp_id)
|
|
api.services_add_filter(udp_id, "pyfilter", "datagrams")
|
|
upf = [f for f in api.services_filters(udp_id)
|
|
if f["kind"] == "pyfilter"][0]["filter_id"]
|
|
why = api.services_set_code_error(udp_id, upf, HTTP_PYFILTER_CODE)
|
|
check("a filter needing a TCP stream is refused on a UDP service",
|
|
why is not None and "UDP" in why and "HttpRequest" in why, str(why))
|
|
check("one written against RawPacket is accepted",
|
|
api.services_set_code(udp_id, upf, UDP_PYFILTER_CODE))
|
|
check("and the service starts with it", api.services_start(udp_id))
|
|
time.sleep(1.2)
|
|
check("a datagram it objects to is dropped",
|
|
echo.exchange(b"carrying PYDENY") is None)
|
|
check("and one it does not is delivered",
|
|
echo.exchange(b"perfectly fine") == b"perfectly fine")
|
|
time.sleep(0.6)
|
|
fns = api.services_functions(udp_id, upf)
|
|
check("the block is counted against the function that made it",
|
|
any(f["blocked"] >= 1 for f in fns), str(fns))
|
|
finally:
|
|
api.services_stop(udp_id)
|
|
api.services_delete(udp_id)
|
|
echo.stop()
|
|
|
|
except SystemExit:
|
|
pass # the external transport finishes early, on purpose
|
|
finally:
|
|
if service_id:
|
|
api.services_stop(service_id)
|
|
api.services_delete(service_id)
|
|
if server:
|
|
server.stop()
|
|
if external:
|
|
external.stop()
|
|
|
|
sep()
|
|
puts("Passed" if exit_code == 0 else "Failed",
|
|
color=colors.green if exit_code == 0 else colors.red, is_bold=True)
|
|
exit(exit_code)
|