mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 16:31:00 +00:00
356 lines
11 KiB
Python
356 lines
11 KiB
Python
from firegex.pyfilters import pyfilter, ACCEPT, ExceptionAction, collect_pyfilters
|
|
from firegex.pyfilters.models import (
|
|
HttpRequest,
|
|
HttpResponse,
|
|
HttpFullRequest,
|
|
HttpFullResponse,
|
|
HttpHistory,
|
|
HttpStreamHistory,
|
|
)
|
|
from firegex.pyfilters.internals import compile, handle_packet
|
|
|
|
|
|
def create_packet_info(payload: bytes, is_input: bool):
|
|
return {
|
|
"data": payload,
|
|
"is_input": is_input,
|
|
"is_ipv6": False,
|
|
"is_tcp": True,
|
|
"src_ip": "10.0.0.9" if is_input else "10.0.0.1",
|
|
"src_port": 51000 if is_input else 80,
|
|
"dst_ip": "10.0.0.1" if is_input else "10.0.0.9",
|
|
"dst_port": 80 if is_input else 51000,
|
|
}
|
|
|
|
|
|
def test_http_history_model():
|
|
req1 = None
|
|
res1 = None
|
|
history_obj = HttpHistory(requests=[req1], responses=[res1])
|
|
assert history_obj.requests == [req1]
|
|
assert history_obj.responses == [res1]
|
|
assert "HttpHistory" in repr(history_obj)
|
|
|
|
|
|
def test_http_history_stream_execution():
|
|
glob = {}
|
|
|
|
code = """
|
|
from firegex.pyfilters import pyfilter, ACCEPT
|
|
from firegex.pyfilters.models import HttpFullRequest, HttpFullResponse, HttpHistory
|
|
|
|
recorded_history = []
|
|
|
|
@pyfilter
|
|
def filter_req(req: HttpFullRequest, history: HttpHistory):
|
|
req_urls = [r.url for r in history.requests]
|
|
resp_statuses = [r.status_code for r in history.responses]
|
|
recorded_history.append(("req", req.url, req_urls, resp_statuses))
|
|
return ACCEPT
|
|
|
|
@pyfilter
|
|
def filter_resp(resp: HttpFullResponse, history: HttpHistory):
|
|
req_urls = [r.url for r in history.requests]
|
|
resp_statuses = [r.status_code for r in history.responses]
|
|
recorded_history.append(("resp", resp.status_code, req_urls, resp_statuses))
|
|
return ACCEPT
|
|
"""
|
|
|
|
glob["__firegex_pyfilter_enabled"] = ["filter_req", "filter_resp"]
|
|
exec(code, glob, glob)
|
|
compile(glob)
|
|
|
|
# 1. First Request
|
|
req1_data = b"GET /first HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
|
glob["__firegex_packet_info"] = create_packet_info(req1_data, is_input=True)
|
|
handle_packet(glob)
|
|
|
|
# 2. First Response
|
|
resp1_data = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"
|
|
glob["__firegex_packet_info"] = create_packet_info(resp1_data, is_input=False)
|
|
handle_packet(glob)
|
|
|
|
# 3. Second Request
|
|
req2_data = b"GET /second HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
|
glob["__firegex_packet_info"] = create_packet_info(req2_data, is_input=True)
|
|
handle_packet(glob)
|
|
|
|
# 4. Second Response
|
|
resp2_data = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"
|
|
glob["__firegex_packet_info"] = create_packet_info(resp2_data, is_input=False)
|
|
handle_packet(glob)
|
|
|
|
recorded = glob["recorded_history"]
|
|
|
|
# 1. First Request: history is empty
|
|
assert recorded[0] == ("req", "/first", [], [])
|
|
|
|
# 2. First Response: past requests contains /first
|
|
assert recorded[1] == ("resp", "OK", ["/first"], [])
|
|
|
|
# 3. Second Request: past requests contains [/first], past responses contains [OK]
|
|
assert recorded[2] == ("req", "/second", ["/first"], ["OK"])
|
|
|
|
# 4. Second Response: past requests contains [/first, /second], past responses contains [OK]
|
|
assert recorded[3] == ("resp", "Not Found", ["/first", "/second"], ["OK"])
|
|
|
|
|
|
def test_http_history_attribute_on_request():
|
|
glob = {}
|
|
|
|
code = """
|
|
from firegex.pyfilters import pyfilter, ACCEPT
|
|
from firegex.pyfilters.models import HttpFullRequest, HttpFullResponse
|
|
|
|
history_lengths = []
|
|
|
|
@pyfilter
|
|
def filter_req(req: HttpFullRequest):
|
|
history_lengths.append((len(req.history.requests), len(req.history.responses)))
|
|
return ACCEPT
|
|
|
|
@pyfilter
|
|
def filter_resp(resp: HttpFullResponse):
|
|
history_lengths.append((len(resp.history.requests), len(resp.history.responses)))
|
|
return ACCEPT
|
|
"""
|
|
|
|
glob["__firegex_pyfilter_enabled"] = ["filter_req", "filter_resp"]
|
|
exec(code, glob, glob)
|
|
compile(glob)
|
|
|
|
# Req 1
|
|
glob["__firegex_packet_info"] = create_packet_info(b"GET /a HTTP/1.1\r\nHost: a\r\n\r\n", is_input=True)
|
|
handle_packet(glob)
|
|
|
|
# Resp 1
|
|
glob["__firegex_packet_info"] = create_packet_info(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", is_input=False)
|
|
handle_packet(glob)
|
|
|
|
# Req 2
|
|
glob["__firegex_packet_info"] = create_packet_info(b"GET /b HTTP/1.1\r\nHost: b\r\n\r\n", is_input=True)
|
|
handle_packet(glob)
|
|
|
|
lengths = glob["history_lengths"]
|
|
assert lengths[0] == (0, 0)
|
|
assert lengths[1] == (1, 0)
|
|
assert lengths[2] == (1, 1)
|
|
|
|
|
|
def test_http_history_invalid_max_size():
|
|
glob = {}
|
|
|
|
code = """
|
|
from firegex.pyfilters import pyfilter, ACCEPT
|
|
from firegex.pyfilters.models import HttpFullRequest
|
|
|
|
@pyfilter
|
|
def filter_req(req: HttpFullRequest):
|
|
return ACCEPT
|
|
"""
|
|
|
|
glob["__firegex_pyfilter_enabled"] = ["filter_req"]
|
|
glob["FGEX_MAX_HISTORY_SIZE"] = "invalid_number"
|
|
exec(code, glob, glob)
|
|
compile(glob)
|
|
|
|
glob["__firegex_packet_info"] = create_packet_info(b"GET /test HTTP/1.1\r\nHost: test\r\n\r\n", is_input=True)
|
|
# Should not raise ValueError, should default to 100
|
|
handle_packet(glob)
|
|
|
|
|
|
def test_http_history_parameter_order():
|
|
glob = {}
|
|
|
|
code = """
|
|
from firegex.pyfilters import pyfilter, ACCEPT
|
|
from firegex.pyfilters.models import HttpFullRequest, HttpHistory
|
|
|
|
calls_log = []
|
|
|
|
@pyfilter
|
|
def filter_history_first(history: HttpHistory, req: HttpFullRequest):
|
|
calls_log.append(("history_first", req.url, len(history.requests)))
|
|
return ACCEPT
|
|
"""
|
|
|
|
glob["__firegex_pyfilter_enabled"] = ["filter_history_first"]
|
|
exec(code, glob, glob)
|
|
compile(glob)
|
|
|
|
glob["__firegex_packet_info"] = create_packet_info(b"GET /first HTTP/1.1\r\nHost: test\r\n\r\n", is_input=True)
|
|
handle_packet(glob)
|
|
|
|
calls = glob["calls_log"]
|
|
# Must be called EXACTLY ONCE with 0 history elements for the first request
|
|
assert len(calls) == 1
|
|
assert calls[0] == ("history_first", "/first", 0)
|
|
|
|
|
|
def test_http_history_property_immutability():
|
|
glob = {}
|
|
immutability_log = []
|
|
|
|
code = """
|
|
from firegex.pyfilters import pyfilter, ACCEPT
|
|
from firegex.pyfilters.models import HttpFullRequest, HttpHistory
|
|
|
|
@pyfilter
|
|
def filter_req(req: HttpFullRequest, history: HttpHistory):
|
|
if len(history.requests) > 0:
|
|
past = history.requests[0]
|
|
immutability_log.append({
|
|
"past_size": past.total_size,
|
|
"past_url": past.url,
|
|
"curr_size": req.total_size,
|
|
"curr_url": req.url,
|
|
})
|
|
return ACCEPT
|
|
"""
|
|
|
|
glob["__firegex_pyfilter_enabled"] = ["filter_req"]
|
|
glob["immutability_log"] = immutability_log
|
|
exec(code, glob, glob)
|
|
compile(glob)
|
|
|
|
# 1. First short request
|
|
req1_data = b"GET /short HTTP/1.1\r\nHost: test\r\n\r\n"
|
|
glob["__firegex_packet_info"] = create_packet_info(req1_data, is_input=True)
|
|
handle_packet(glob)
|
|
|
|
# 2. Second longer request
|
|
req2_data = b"POST /large_path HTTP/1.1\r\nHost: test\r\nContent-Length: 5\r\n\r\nHELLO"
|
|
glob["__firegex_packet_info"] = create_packet_info(req2_data, is_input=True)
|
|
handle_packet(glob)
|
|
|
|
entries = glob["immutability_log"]
|
|
assert len(entries) == 1
|
|
# Historical request 1 size must equal 14 (/short), current size 39 (/large_path)
|
|
assert entries[0]["past_size"] == 14
|
|
assert entries[0]["past_url"] == "/short"
|
|
assert entries[0]["curr_size"] == 39
|
|
assert entries[0]["curr_url"] == "/large_path"
|
|
|
|
|
|
def test_http_history_negative_max_size():
|
|
glob = {}
|
|
|
|
code = """
|
|
from firegex.pyfilters import pyfilter, ACCEPT
|
|
from firegex.pyfilters.models import HttpFullRequest
|
|
|
|
@pyfilter
|
|
def filter_req(req: HttpFullRequest):
|
|
return ACCEPT
|
|
"""
|
|
|
|
glob["__firegex_pyfilter_enabled"] = ["filter_req"]
|
|
glob["FGEX_MAX_HISTORY_SIZE"] = "-5"
|
|
exec(code, glob, glob)
|
|
compile(glob)
|
|
|
|
glob["__firegex_packet_info"] = create_packet_info(b"GET /test HTTP/1.1\r\nHost: test\r\n\r\n", is_input=True)
|
|
# Should not raise ValueError for maxlen=-5, should clamp to 0
|
|
handle_packet(glob)
|
|
|
|
|
|
def test_http_history_pyfilters_import():
|
|
from firegex.pyfilters import HttpHistory as HH1, HttpStreamHistory as HH2
|
|
assert HH1 is HttpHistory
|
|
assert HH2 is HttpStreamHistory
|
|
|
|
|
|
def test_http_history_list_mutation():
|
|
history_obj = HttpHistory(requests=["dummy_req"], responses=["dummy_resp"])
|
|
reqs = history_obj.requests
|
|
reqs.clear()
|
|
assert len(history_obj.requests) == 1
|
|
assert history_obj.requests == ["dummy_req"]
|
|
|
|
resps = history_obj.responses
|
|
resps.append("extra_resp")
|
|
assert len(history_obj.responses) == 1
|
|
assert history_obj.responses == ["dummy_resp"]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
test_http_history_model()
|
|
test_http_history_stream_execution()
|
|
test_http_history_attribute_on_request()
|
|
test_http_history_invalid_max_size()
|
|
test_http_history_parameter_order()
|
|
test_http_history_property_immutability()
|
|
test_http_history_negative_max_size()
|
|
test_http_history_pyfilters_import()
|
|
test_http_history_list_mutation()
|
|
print("All HTTP history unit tests passed successfully!")
|
|
|
|
|
|
|
|
|
|
|
|
def test_invalid_encoding_action_is_settable():
|
|
"""The documented knob has to actually be reachable.
|
|
|
|
`FGEX_INVALID_ENCODING_ACTION` is documented as taking an `ExceptionAction`, and the
|
|
property that stores it accepts nothing else — but the gate that read it out of the
|
|
module checked `Action`, a different enum. So the documented value was silently
|
|
ignored and the only value that got past the gate raised in the setter: the knob
|
|
could not be set at all, in either direction, and the symptom was a service that
|
|
kept rejecting connections its filter had been told to let through.
|
|
"""
|
|
from firegex.pyfilters.internals.data import DataStreamCtx
|
|
|
|
glob = {}
|
|
code = """
|
|
from firegex.pyfilters import pyfilter, ACCEPT, ExceptionAction
|
|
from firegex.pyfilters.models import HttpRequest
|
|
|
|
FGEX_INVALID_ENCODING_ACTION = ExceptionAction.ACCEPT
|
|
|
|
@pyfilter
|
|
def anything(req: HttpRequest):
|
|
return ACCEPT
|
|
"""
|
|
glob["__firegex_pyfilter_enabled"] = ["anything"]
|
|
exec(code, glob, glob)
|
|
compile(glob)
|
|
assert DataStreamCtx(glob, init_pkt=False).invalid_encoding_action is ExceptionAction.ACCEPT
|
|
|
|
|
|
def test_filters_are_collected_in_definition_order():
|
|
"""The order a file defines its filters in is the order they run in.
|
|
|
|
They used to be registered by name into a `set` on the decorator, so the order was
|
|
whatever hashing produced — and since the first filter to answer anything but ACCEPT
|
|
ends the packet, that decided which function got the block and whether an earlier
|
|
rewrite survived. Nothing about the file could tell you which.
|
|
|
|
The names below are deliberately not in alphabetical order, and deliberately not in
|
|
an order a hash would be likely to reproduce.
|
|
"""
|
|
from firegex.pyfilters.internals import get_filter_names
|
|
|
|
names = ["zulu", "alpha", "mike", "bravo", "yankee", "charlie", "xray", "delta"]
|
|
code = "from firegex.pyfilters import pyfilter, ACCEPT\n" \
|
|
"from firegex.pyfilters.models import RawPacket\n" + "".join(
|
|
f"@pyfilter\ndef {n}(p: RawPacket):\n return ACCEPT\n" for n in names
|
|
)
|
|
assert get_filter_names(code) == names
|
|
|
|
namespace = {}
|
|
exec(code, namespace, namespace)
|
|
assert collect_pyfilters(namespace) == names
|
|
|
|
|
|
def test_a_filter_is_the_function_itself():
|
|
"""`@pyfilter` marks and returns the function, it does not wrap it.
|
|
|
|
The wrapper it used to return forwarded `*args` and did nothing else, at the cost of
|
|
a frame in every traceback an operator has to read to find their own mistake.
|
|
"""
|
|
def original(p):
|
|
return ACCEPT
|
|
|
|
assert pyfilter(original) is original
|