Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 42 additions & 49 deletions flaskapi_guard/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,94 +426,84 @@ def _handle_preflight(
preflight = self._cors_handler.build_preflight_response(request_headers)
return self._build_flask_preflight_response(preflight)

def _check_passthrough(
self,
guard_request: FlaskGuardRequest,
) -> None:
assert self.validator is not None
if not guard_request.client_host:
g.guard_passthrough = True
return
if self.validator.is_path_excluded(guard_request):
guard_request.state.guard_exclusion_scoped = True
return
def _call_view(self, _guard_request: Any) -> FlaskGuardResponse:
from flask import current_app, request

def _check_security_bypass(
self,
guard_request: FlaskGuardRequest,
route_config: RouteConfig | None,
) -> None:
assert self.bypass_handler is not None
assert self.route_resolver is not None
assert self.event_bus is not None
assert self.config is not None
if not route_config:
return
if not self.route_resolver.should_bypass_check("all", route_config):
return
self.event_bus.send_middleware_event(
event_type="security_bypass",
request=guard_request,
action_taken="all_checks_bypassed",
reason="Route configured to bypass all security checks",
bypassed_checks=list(route_config.bypassed_checks),
endpoint=str(guard_request.url_path),
)
if not self.config.passive_mode:
g.guard_passthrough = True
return
assert request.endpoint is not None
view = current_app.ensure_sync(current_app.view_functions[request.endpoint])
rv = view(**(request.view_args or {}))
return FlaskGuardResponse(current_app.make_response(rv))

def _before_request(self) -> Response | None:
def _early_response(
self, early: GuardResponse | None, request_headers: dict[str, str]
) -> Response | None:
if early is None:
return None
return self._attach_cors_to_blocked(unwrap_response(early), request_headers)

def _guard_request_phase(self) -> Response | None:
from flask import request

assert self.config is not None
assert self.bypass_handler is not None
assert self.route_resolver is not None
assert self.behavioral_processor is not None

init_failure = self._ensure_initialized()
request_headers = dict(request.headers)
if init_failure is not None:
return self._attach_cors_to_blocked(init_failure, dict(request.headers))
return self._attach_cors_to_blocked(init_failure, request_headers)

g.request_start_time = time.time()
guard_request = FlaskGuardRequest(request)
self._populate_guard_state(guard_request)

request_headers = dict(request.headers)

preflight_response = self._handle_preflight(
guard_request, request.method, request_headers
)
if preflight_response is not None:
return preflight_response

self._check_passthrough(guard_request)

if getattr(g, "guard_passthrough", False):
return None
early = self._early_response(
self.bypass_handler.handle_passthrough(guard_request, self._call_view),
request_headers,
)
if early is not None:
return early

client_ip = extract_client_ip(guard_request, self.config, self.agent_handler)
route_config = self.route_resolver.get_route_config(guard_request)

g.client_ip = client_ip
g.route_config = route_config

self._check_security_bypass(guard_request, route_config)

if getattr(g, "guard_passthrough", False):
return None
early = self._early_response(
self.bypass_handler.handle_security_bypass(
guard_request, self._call_view, route_config
),
request_headers,
)
if early is not None:
return early

blocking = self._execute_security_pipeline(guard_request)
if blocking is not None:
return self._attach_cors_to_blocked(blocking, request_headers)

self._process_behavioral_usage(guard_request, client_ip, route_config)

return None

def _before_request(self) -> Response | None:
early = self._guard_request_phase()
if early is None:
g.guard_response_pass = True
return early

def _after_request(self, response: Response) -> Response:
from flask import request

if not getattr(g, "guard_response_pass", False):
return response

assert self.response_factory is not None
assert self.behavioral_processor is not None

Expand All @@ -529,6 +519,9 @@ def _after_request(self, response: Response) -> Response:
response_time,
route_config,
process_behavioral_rules=self.behavioral_processor.process_return_rules,
process_global_behavioral_rules=(
self.behavioral_processor.process_global_return_rules
),
)
flask_response = unwrap_response(result)

Expand Down
186 changes: 186 additions & 0 deletions tests/test_engine_parity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"""Engine parity: the adapter routes through the engine exactly like fastapi-guard.

Real Flask test client, real engine bypass handler and response factory. The
metrics collector is spied on (it still runs) to prove which responses reach
the engine response pass.
"""

from typing import Any

from flask import Flask
from guard_core.protocols.response_protocol import GuardResponse
from guard_core.sync.handlers.ipban_handler import reset_global_state

from flaskapi_guard import SecurityConfig, SecurityDecorator
from flaskapi_guard.extension import FlaskAPIGuard

NO_CLIENT: dict[str, Any] = {"REMOTE_ADDR": None}


def _build(
**overrides: Any,
) -> tuple[Flask, FlaskAPIGuard, list[str]]:
base: dict[str, Any] = {
"enable_redis": False,
"enable_penetration_detection": False,
"whitelist": None,
"blacklist": [],
"exclude_paths": ["/health"],
"security_headers": {"enabled": True},
}
base.update(overrides)
config = SecurityConfig(**base)
app = Flask(__name__)
app.config["TESTING"] = True
guard = FlaskAPIGuard(app, config=config)
sec = SecurityDecorator(config)
guard.set_decorator_handler(sec)
metrics: list[str] = []
assert guard.metrics_collector is not None
real = guard.metrics_collector.collect_request_metrics

def spy(request: Any, response_time: float, status_code: int) -> None:
metrics.append(request.url_path)
real(request, response_time, status_code)

guard.metrics_collector.collect_request_metrics = spy # type: ignore[method-assign]

@app.route("/health")
def health() -> dict[str, str]:
return {"message": "ok"}

@app.route("/open")
def open_() -> dict[str, str]:
return {"message": "Echo response"}

@app.route("/bypassed")
@sec.bypass(["all"])
def bypassed() -> dict[str, str]:
return {"message": "real view"}

return app, guard, metrics


def test_excluded_path_reaches_view_with_scoped_checks_and_response_pass() -> None:
app, _guard, metrics = _build(blacklist=["10.9.9.9"])
client = app.test_client()

ok = client.get("/health")
assert ok.status_code == 200
assert ok.get_json() == {"message": "ok"}
assert ok.headers.get("X-Content-Type-Options") == "nosniff"
assert metrics == ["/health"]

blocked = client.get("/health", environ_overrides={"REMOTE_ADDR": "10.9.9.9"})
assert blocked.status_code == 403
assert metrics == ["/health"]


def test_no_client_address_gets_fail_secure_403_by_default() -> None:
app, _guard, metrics = _build()
client = app.test_client()

resp = client.get("/open", environ_overrides=NO_CLIENT)

assert resp.status_code == 403
assert "Client address could not be determined" in resp.get_data(as_text=True)
assert metrics == []


def test_no_client_address_on_excluded_path_is_scoped_not_bypassed() -> None:
app, _guard, metrics = _build()
client = app.test_client()

resp = client.get("/health", environ_overrides=NO_CLIENT)

assert resp.status_code == 200
assert metrics == ["/health"]


def test_no_client_address_fail_secure_false_runs_the_pipeline() -> None:
app, _guard, metrics = _build(fail_secure=False)
client = app.test_client()

resp = client.get("/open", environ_overrides=NO_CLIENT)

assert resp.status_code == 200
assert metrics == ["/open"]


def test_route_bypass_reaches_real_view_and_skips_response_pass() -> None:
seen: list[int] = []

def modifier(response: GuardResponse) -> GuardResponse:
seen.append(response.status_code)
response.headers["X-Modified"] = "1"
return response

app, _guard, metrics = _build(custom_response_modifier=modifier)
client = app.test_client()

resp = client.get("/bypassed")

assert resp.status_code == 200
assert resp.get_json() == {"message": "real view"}
assert resp.headers["X-Modified"] == "1"
assert seen == [200]
assert metrics == []
assert "X-Content-Type-Options" not in resp.headers


def test_blocked_request_emits_no_request_metrics() -> None:
app, _guard, metrics = _build(blacklist=["10.9.9.9"])
client = app.test_client()

blocked = client.get("/open", environ_overrides={"REMOTE_ADDR": "10.9.9.9"})
assert blocked.status_code == 403
assert metrics == []

allowed = client.get("/open")
assert allowed.status_code == 200
assert metrics == ["/open"]


def test_preflight_emits_no_request_metrics() -> None:
app, _guard, metrics = _build(
enable_cors=True, cors_allow_origins=["https://example.com"]
)
client = app.test_client()

resp = client.options(
"/open",
headers={
"Origin": "https://example.com",
"Access-Control-Request-Method": "GET",
},
)

assert resp.status_code == 200
assert resp.headers["Access-Control-Allow-Origin"] == "https://example.com"
assert metrics == []


def test_global_behavior_return_rule_fires() -> None:
reset_global_state()
app, _guard, _metrics = _build(
behavior_scan_response_body=True,
global_behavior_rules=[
{
"rule_type": "return_pattern",
"threshold": 2,
"window": 60,
"pattern": "json:message==Echo response",
"action": "ban",
"ban_duration": 30,
}
],
)
client = app.test_client()

remote = {"REMOTE_ADDR": "203.0.113.9"}
codes: list[int] = [
client.get("/open", environ_overrides=remote).status_code for _ in range(5)
]

assert codes[0] == 200
assert 403 in codes
6 changes: 4 additions & 2 deletions tests/test_extension/test_security_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -1695,8 +1695,10 @@ def root() -> dict[str, str]:
):
with app.test_client() as client:
response = client.get("/")
assert response.status_code == 200
assert response.get_json() == {"msg": "ok"}
assert response.status_code == 403
assert "Client address could not be determined" in response.get_data(
as_text=True
)

with app.test_client() as client:
baseline = client.get("/")
Expand Down
Loading