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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@ Changelog

___

v3.0.0 (2026-04-26)
-------------------

Pipeline-first CORS via guard_core.cors_handler (v3.0.0)
--------------------------------------------------------

- **Breaking** — Preflight `OPTIONS` requests are now subject to the security pipeline (previously short-circuited inside the extension). Banned IPs and rate-limited clients can no longer preflight freely.
- **Breaking** — CORS is now configured exclusively via `SecurityConfig.cors_*` fields. The extension wires `_before_request` / `_after_request` automatically; no separate `configure_cors` entry point.
- **Fixed** — Cross-origin preflight requests to passthrough paths (e.g. `exclude_paths=["/health"]`) now receive a valid CORS response. Preflight handling runs ahead of the passthrough/bypass short-circuit so the browser permission check works for excluded paths.
- **Fixed** — Cross-origin GETs to passthrough/bypass paths now carry CORS headers on their responses, matching the previous outer-CORSMiddleware semantics.
- **Internal** — Both lifecycle hooks delegate to the new shared `guard_core.sync.handlers.cors_handler.CorsHandler`. Removed all `[[tool.mypy.overrides]] ignore_missing_imports = true` / `follow_imports = "skip"` blocks; replaced with proper inline-typed packages and `django-stubs`. Stripped `[tool.uv.sources] guard-core` local-path block from committed pyproject.toml. Added `guard-agent` to dev dependencies (was previously declared only in deptry's per-rule-ignores).
- **Requires** — `guard-core>=2.2.0`.

___

v2.2.0 (2026-04-25)
-------------------

Expand Down
119 changes: 81 additions & 38 deletions flaskapi_guard/extension.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import logging
import time
from typing import Any
from typing import Any, cast

from flask import Flask, Response, g
from guard_core.decorators.base import BaseSecurityDecorator, RouteConfig
from guard_core.models import SecurityConfig
from guard_core.protocols.response_protocol import GuardResponse
from guard_core.sync.core.behavioral import BehavioralContext, BehavioralProcessor
from guard_core.sync.core.bypass import BypassContext, BypassHandler
from guard_core.sync.core.checks.pipeline import SecurityCheckPipeline
Expand All @@ -13,9 +13,16 @@
from guard_core.sync.core.responses import ErrorResponseFactory, ResponseContext
from guard_core.sync.core.routing import RouteConfigResolver, RoutingContext
from guard_core.sync.core.validation import RequestValidator, ValidationContext
from guard_core.sync.decorators.base import BaseSecurityDecorator, RouteConfig
from guard_core.sync.handlers.cloud_handler import cloud_handler
from guard_core.sync.handlers.cors_handler import (
CorsHandler,
CorsPreflightResponse,
is_preflight,
)
from guard_core.sync.handlers.ratelimit_handler import RateLimitManager
from guard_core.sync.handlers.security_headers_handler import security_headers_manager
from guard_core.sync.protocols.middleware_protocol import SyncGuardMiddlewareProtocol
from guard_core.sync.utils import extract_client_ip, setup_custom_logging

from flaskapi_guard.adapters import (
Expand Down Expand Up @@ -52,6 +59,7 @@ def __init__(
self.validator: RequestValidator | None = None
self.bypass_handler: BypassHandler | None = None
self.behavioral_processor: BehavioralProcessor | None = None
self._cors_handler: CorsHandler | None = None
self._app: Flask | None = None
self._guard_response_factory = FlaskResponseFactory()

Expand Down Expand Up @@ -214,6 +222,9 @@ def init_app(self, app: Flask, config: SecurityConfig | None = None) -> None:

self._build_security_pipeline()
self._initialize_handlers()
self._cors_handler = (
CorsHandler(self.config) if self.config.enable_cors else None
)

app.before_request(self._before_request)
app.after_request(self._after_request)
Expand Down Expand Up @@ -250,24 +261,25 @@ def _build_security_pipeline(self) -> None:
UserAgentCheck,
)

middleware = cast(SyncGuardMiddlewareProtocol, self)
checks = [
RouteConfigCheck(self),
EmergencyModeCheck(self),
HttpsEnforcementCheck(self),
RequestLoggingCheck(self),
RequestSizeContentCheck(self),
RequiredHeadersCheck(self),
AuthenticationCheck(self),
ReferrerCheck(self),
CustomValidatorsCheck(self),
TimeWindowCheck(self),
CloudIpRefreshCheck(self),
IpSecurityCheck(self),
CloudProviderCheck(self),
UserAgentCheck(self),
RateLimitCheck(self),
SuspiciousActivityCheck(self),
CustomRequestCheck(self),
RouteConfigCheck(middleware),
EmergencyModeCheck(middleware),
HttpsEnforcementCheck(middleware),
RequestLoggingCheck(middleware),
RequestSizeContentCheck(middleware),
RequiredHeadersCheck(middleware),
AuthenticationCheck(middleware),
ReferrerCheck(middleware),
CustomValidatorsCheck(middleware),
TimeWindowCheck(middleware),
CloudIpRefreshCheck(middleware),
IpSecurityCheck(middleware),
CloudProviderCheck(middleware),
UserAgentCheck(middleware),
RateLimitCheck(middleware),
SuspiciousActivityCheck(middleware),
CustomRequestCheck(middleware),
]

self.security_pipeline = SecurityCheckPipeline(checks)
Expand Down Expand Up @@ -377,12 +389,22 @@ def _before_request(self) -> Response | None:
guard_request = FlaskGuardRequest(request)
self._populate_guard_state(guard_request)

if self.config.enable_cors and request.method == "OPTIONS":
return self._handle_preflight(request)
request_headers = dict(request.headers)

if self._cors_handler is not None and is_preflight(
request.method, request_headers
):
blocking = self._execute_security_pipeline(guard_request)
if blocking is not None:
return self._attach_cors_to_blocked(blocking, request_headers)
preflight = self._cors_handler.build_preflight_response(request_headers)
return self._build_flask_preflight_response(preflight)

passthrough = self.bypass_handler.handle_passthrough(guard_request)
if passthrough is not None:
return unwrap_response(passthrough)
return self._attach_cors_to_blocked(
unwrap_response(passthrough), request_headers
)

client_ip = extract_client_ip(guard_request, self.config, self.agent_handler)
route_config = self.route_resolver.get_route_config(guard_request)
Expand All @@ -394,11 +416,13 @@ def _before_request(self) -> Response | None:
guard_request, route_config=route_config
)
if bypass is not None:
return unwrap_response(bypass)
return self._attach_cors_to_blocked(
unwrap_response(bypass), request_headers
)

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

self._process_behavioral_usage(guard_request, client_ip, route_config)

Expand All @@ -423,14 +447,36 @@ def _after_request(self, response: Response) -> Response:
route_config,
process_behavioral_rules=self.behavioral_processor.process_return_rules,
)
return unwrap_response(result)
flask_response = unwrap_response(result)

def _handle_preflight(self, request: Any) -> Response:
assert self.response_factory is not None
guard_response = self._guard_response_factory.create_response("", 204)
origin = request.headers.get("origin", "")
self.response_factory.apply_cors_headers(guard_response, origin)
return unwrap_response(guard_response)
if self._cors_handler is not None:
cors_headers = self._cors_handler.build_response_headers(
dict(request.headers)
)
for k, v in cors_headers.items():
flask_response.headers[k] = v

return flask_response

def _attach_cors_to_blocked(
self, response: Response, request_headers: dict[str, str]
) -> Response:
if self._cors_handler is not None:
cors_headers = self._cors_handler.build_response_headers(request_headers)
for k, v in cors_headers.items():
response.headers[k] = v
return response

def _build_flask_preflight_response(
self, preflight: CorsPreflightResponse
) -> Response:
flask_response = Response(
preflight.body,
status=preflight.status_code,
)
for k, v in preflight.headers.items():
flask_response.headers[k] = v
return flask_response

def _check_time_window(self, time_restrictions: dict[str, str]) -> bool:
assert self.validator is not None
Expand Down Expand Up @@ -488,17 +534,14 @@ def refresh_cloud_ip_ranges(self) -> None:
if not self.config.block_cloud_providers:
return

cloud_handler.refresh(
self.config.block_cloud_providers,
ttl=self.config.cloud_ip_refresh_interval,
)
cloud_handler.refresh(self.config.block_cloud_providers)
self.last_cloud_ip_refresh = int(time.time())

def create_error_response(
self, status_code: int, default_message: str
) -> FlaskGuardResponse:
) -> GuardResponse:
assert self.response_factory is not None
result: FlaskGuardResponse = self.response_factory.create_error_response(
result: GuardResponse = self.response_factory.create_error_response(
status_code, default_message
)
return result
Expand Down
18 changes: 4 additions & 14 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "flaskapi_guard"
version = "2.2.0"
version = "3.0.0"
description = "A security library for Flask to control IPs, log requests, and detect penetration attempts."
authors = [
{name = "Renzo Franceschini", email = "rennf93@users.noreply.github.com"}
Expand Down Expand Up @@ -35,6 +35,7 @@ Homepage = "https://github.com/rennf93/flaskapi-guard"
dev = [
"bandit[toml]",
"deptry",
"guard-agent",
"httpx",
"mkdocs",
"mkdocstrings",
Expand Down Expand Up @@ -124,18 +125,6 @@ warn_no_return = true
warn_unreachable = true
exclude = ["vulture_whitelist.py", ".venv"]

[[tool.mypy.overrides]]
module = "redis.*"
follow_imports = "skip"

[[tool.mypy.overrides]]
module = "guard_agent.*"
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = ["guard_core", "guard_core.*", "guard_core.sync.*", "guard_core.sync.handlers.*", "guard_core.sync.protocols.*", "guard_core.sync.core.*"]
ignore_missing_imports = true
follow_imports = "skip"

[tool.pymarkdown.plugins.md007]
# MD007 - Unordered list indentation (set to 2 spaces)
Expand Down Expand Up @@ -231,8 +220,9 @@ ignore = []
exclude = ["tests", ".venv", "vulture_whitelist.py", "examples", "docs", "build"]
extend_exclude = ["conftest.py", "setup.py"]
known_first_party = ["flaskapi_guard", "guard_core"]
per_rule_ignores = { "DEP001" = ["guard_agent"], "DEP002" = [
per_rule_ignores = { "DEP002" = [
"bandit",
"guard-agent",
"guard-core",
"deptry",
"mkdocs",
Expand Down
12 changes: 7 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import os
from collections.abc import Generator
from pathlib import Path
from typing import Any, cast

import pytest
from flask import Flask
from guard_core.models import SecurityConfig
from guard_core.protocols.geo_ip_protocol import GeoIPHandler
from guard_core.sync.handlers.cloud_handler import cloud_handler
from guard_core.sync.handlers.ipban_handler import reset_global_state
from guard_core.sync.handlers.ipinfo_handler import IPInfoManager
Expand Down Expand Up @@ -41,7 +43,7 @@ def reset_state() -> Generator[None, None, None]:
@pytest.fixture
def security_config() -> SecurityConfig:
return SecurityConfig(
geo_ip_handler=IPInfoManager(IPINFO_TOKEN, None),
geo_ip_handler=cast(GeoIPHandler, IPInfoManager(IPINFO_TOKEN, None)),
enable_redis=False,
enable_penetration_detection=False,
whitelist=["127.0.0.1"],
Expand All @@ -68,7 +70,7 @@ def security_config() -> SecurityConfig:
@pytest.fixture
def flaskapi_guard_app() -> Generator[tuple[Flask, FlaskAPIGuard], None, None]:
config = SecurityConfig(
geo_ip_handler=IPInfoManager(IPINFO_TOKEN),
geo_ip_handler=cast(GeoIPHandler, IPInfoManager(IPINFO_TOKEN)),
enable_penetration_detection=False,
whitelist=[],
blacklist=[],
Expand All @@ -90,7 +92,7 @@ def ipinfo_db_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
@pytest.fixture
def security_config_redis(ipinfo_db_path: Path) -> SecurityConfig:
return SecurityConfig(
geo_ip_handler=IPInfoManager(IPINFO_TOKEN, ipinfo_db_path),
geo_ip_handler=cast(GeoIPHandler, IPInfoManager(IPINFO_TOKEN, ipinfo_db_path)),
redis_url=REDIS_URL,
redis_prefix=REDIS_PREFIX,
enable_penetration_detection=False,
Expand Down Expand Up @@ -129,7 +131,7 @@ def redis_cleanup() -> None:
"flaskapi_guard:*",
"*rate_limit:*",
]:
keys = r.keys(pattern)
keys = cast(list[Any], r.keys(pattern))
if keys:
r.delete(*keys)
finally:
Expand All @@ -142,7 +144,7 @@ def redis_cleanup() -> None:
def reset_rate_limiter() -> None:
try:
config = SecurityConfig(
geo_ip_handler=IPInfoManager(IPINFO_TOKEN, None),
geo_ip_handler=cast(GeoIPHandler, IPInfoManager(IPINFO_TOKEN, None)),
enable_redis=False,
)
rate_limit = rate_limit_handler(config)
Expand Down
Loading
Loading