Skip to content

[BUG] Two SecurityConfig values silently disable a control while it still reports enabled #79

Closed

Description

@RomanPszonka

Both accepted without a warning, both turn something off:

whitelist entries with prefix length 0. A whitelist is checked before the
blacklist, so whitelist=["0.0.0.0/0"] (or ["::/0"]) nullifies the entire
blocklist:

blacklist only                         -> HTTP 403
blacklist + whitelist ['0.0.0.0/0']    -> HTTP 200
blacklist + whitelist ['::/0']         -> HTTP 200

Plausible as a first attempt at "allow everyone for now" during a rollout, and
it leaves the blocklist in the config, looking enforced.

enabled_detection_categories=frozenset(). Detection reports enabled and
matches nothing:

default categories                         -> SQLi probe HTTP 400, enable_penetration_detection=True
enabled_detection_categories=frozenset()   -> SQLi probe HTTP 200, enable_penetration_detection=True

Reachable through a set comprehension that filters to nothing, which is how
category lists tend to get built from an env var.

Related, same shape: trusted_proxies=["0.0.0.0/0"] is accepted, which makes
every peer trusted to set X-Forwarded-For.

poc_failopen_config.py
"""Two SecurityConfig values that turn a control off without saying so.

    uv run python docs/upstream-guard-core/followups/poc_failopen_config.py
"""

import logging
from importlib.metadata import version

logging.getLogger("guard_core").setLevel(logging.CRITICAL)

from fastapi import FastAPI  # noqa: E402
from guard.middleware import SecurityMiddleware  # noqa: E402
from guard_core.models import SecurityConfig  # noqa: E402
from starlette.testclient import TestClient  # noqa: E402


def app(**cfg) -> FastAPI:
    application = FastAPI()
    application.add_middleware(
        SecurityMiddleware, config=SecurityConfig(enable_redis=False, **cfg)
    )

    @application.get("/")
    async def root():
        return {"ok": True}

    return application


print(f"guard-core {version('guard-core')} / fastapi-guard {version('fastapi-guard')}")

print("\n  a whitelist entry with prefix length 0 overrides the blacklist")
for label, cfg in (
    ("blacklist only", {"blacklist": ["66.66.66.0/24"]}),
    ("blacklist + whitelist ['0.0.0.0/0']", {"blacklist": ["66.66.66.0/24"], "whitelist": ["0.0.0.0/0"]}),
    ("blacklist + whitelist ['::/0']", {"blacklist": ["66.66.66.0/24"], "whitelist": ["::/0", "0.0.0.0/0"]}),
):
    with TestClient(app(**cfg), client=("66.66.66.9", 1)) as client:
        print(f"    {label:38} -> HTTP {client.get('/').status_code}")

print("\n  an empty category set switches detection off while it still reports enabled")
sqli = {"q": "' OR 1=1 --"}
for label, cfg in (
    ("default categories", {}),
    ("enabled_detection_categories=frozenset()", {"enabled_detection_categories": frozenset()}),
):
    config = SecurityConfig(enable_redis=False, **cfg)
    with TestClient(app(**cfg), client=("203.0.113.9", 1)) as client:
        status = client.get("/", params=sqli).status_code
    print(f"    {label:42} -> SQLi probe HTTP {status}, "
          f"enable_penetration_detection={config.enable_penetration_detection}")

print("\n  neither construction warns:")
import warnings  # noqa: E402

for label, cfg in (
    ("whitelist=['0.0.0.0/0']", {"whitelist": ["0.0.0.0/0"]}),
    ("enabled_detection_categories=frozenset()", {"enabled_detection_categories": frozenset()}),
    ("trusted_proxies=['0.0.0.0/0']", {"trusted_proxies": ["0.0.0.0/0"]}),
):
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        SecurityConfig(enable_redis=False, **cfg)
    print(f"    {label:42} -> {len(caught)} warnings")

Fix: warn (or reject) on a whitelist or trusted_proxies entry with prefix
length 0, and on an empty enabled_detection_categories while
enable_penetration_detection is True. A field validator on each is enough —
these are the configurations where a warning at startup is worth more than any
amount of documentation.



Found while re-checking a private audit against the current release. Verified on guard-core 3.13.0, CPython 3.14.

Activity

  1. rennf93 commented on Aug 25, 2026

    @rennf93
    Member

    Thanks @RomanPszonka for opening the issue! By @rennf93's directive:

    Reproduced all three. Rulings I've made:

    1. Whitelist entries with prefix length 0: declined as a defect. Whitelist-over-blacklist precedence is documented (the whitelist field description and docs/internals/ip-management.md) and pinned by tests since 3.2.0. 0.0.0.0/0 is that rule at its logical extreme, and "allow everyone" is what the operator wrote.
    2. enabled_detection_categories=frozenset(): the primitive's behaviour (empty set enables nothing) is intended and tested, but the config surface is undocumented and warns nothing. 3.14.0 logs a startup warning when the set is empty while enable_penetration_detection is True, and documents it.
    3. trusted_proxies=["0.0.0.0/0"]: agreed this is the serious one; it is the CVE-2025-46814 shape reached by configuration. 3.14.0 logs a startup warning on a prefix-length-0 trusted_proxies entry.

    Both are log warnings rather than warnings.warn, consistent with the private-IP ban warning shipped in a2d8e00.

  2. self-assigned this
    on Aug 25, 2026
  3. added
    bugConfirmed defect
    securitySecurity-related issue (use private reporting first!)
    area: modelsTouches guard_core/models.py
    documentationDocs, README, CHANGELOG, governance files
    on Aug 26, 2026
  4. linked a pull request that will close this issuerelease(v3.14.0): post-3.13.0 hardening #82on Aug 26, 2026
  5. rennf93 commented on Aug 26, 2026

    @rennf93
    Member

    Shipped in guard-core 3.14.0 (PyPI), merged in #82 (500d4ef).

    Part 2: SecurityConfig now warns at construction on a prefix-0 trusted_proxies entry (0.0.0.0/0, ::/0) and on an empty enabled_detection_categories while detection is enabled. Part 1 (a 0.0.0.0/0 whitelist taking precedence over the blacklist) stays as designed, per the earlier reply; the precedence is documented and tested.

  6. RomanPszonka commented on Aug 26, 2026

    @RomanPszonka
    Author

    Two of the three landed — the empty-category warning and the /0 check on
    trusted_proxies are both in 3.14.0. The whitelist half is still open, and
    it is the one of the three that turns an access control off:

      blacklist only                     -> HTTP 403
      + whitelist ['0.0.0.0/0']          -> HTTP 200
      + whitelist ['::/0','0.0.0.0/0']   -> HTTP 200
    
      trusted_proxies=['0.0.0.0/0']              -> warns
      enabled_detection_categories=frozenset()   -> warns
      whitelist=['0.0.0.0/0']                    -> nothing
    

    _is_prefix_zero_trusted_proxy_entry is already written and already does the
    right test; it just is not wired to whitelist.

    script
    """guard-core#79: the /0 whitelist half is still open.
    
        uv run python docs/upstream-guard-core/followups-314/poc_whitelist_zero.py
    
    3.14.0 closed two of the three things in that issue -- an empty
    `enabled_detection_categories` now warns, and a `/0` entry in `trusted_proxies`
    now warns. A `/0` entry in `whitelist` still silently nullifies the blacklist,
    and the whitelist is the one of the three that turns an access control off.
    """
    
    import logging
    import warnings
    from importlib.metadata import version
    
    MESSAGES: list[str] = []
    
    
    class Capture(logging.Handler):
        def emit(self, record: logging.LogRecord) -> None:
            if record.levelno >= logging.WARNING:
                MESSAGES.append(record.getMessage())
    
    
    CAPTURE = Capture()
    
    
    def arm_capture() -> None:
        """guard-core replaces its own logger's handlers when a middleware is built."""
        guard_logger = logging.getLogger("guard_core")
        if CAPTURE not in guard_logger.handlers:
            guard_logger.addHandler(CAPTURE)
        guard_logger.setLevel(logging.WARNING)
        for name in ("guard_core.models", "guard_core.handlers"):
            logging.getLogger(name).setLevel(logging.WARNING)
    
    
    arm_capture()
    
    from fastapi import FastAPI  # noqa: E402
    from guard.middleware import SecurityMiddleware  # noqa: E402
    from guard_core.models import SecurityConfig  # noqa: E402
    from starlette.testclient import TestClient  # noqa: E402
    
    
    def app(**cfg) -> FastAPI:
        application = FastAPI()
        application.add_middleware(
            SecurityMiddleware, config=SecurityConfig(enable_redis=False, **cfg)
        )
    
        @application.get("/")
        async def root():
            return {"ok": True}
    
        return application
    
    
    print(f"guard-core {version('guard-core')} / fastapi-guard {version('fastapi-guard')}")
    
    print("\n  effect on a blacklisted address:")
    for label, cfg in (
        ("blacklist only", {"blacklist": ["66.66.66.0/24"]}),
        ("+ whitelist ['0.0.0.0/0']", {"blacklist": ["66.66.66.0/24"], "whitelist": ["0.0.0.0/0"]}),
        ("+ whitelist ['::/0','0.0.0.0/0']",
         {"blacklist": ["66.66.66.0/24"], "whitelist": ["::/0", "0.0.0.0/0"]}),
    ):
        with TestClient(app(**cfg), client=("66.66.66.9", 1)) as client:
            print(f"    {label:34} -> HTTP {client.get('/').status_code}")
    
    print("\n  what each /0 field warns about at construction:")
    for label, cfg in (
        ("trusted_proxies=['0.0.0.0/0']", {"trusted_proxies": ["0.0.0.0/0"]}),
        ("whitelist=['0.0.0.0/0']", {"whitelist": ["0.0.0.0/0"]}),
        ("enabled_detection_categories=frozenset()", {"enabled_detection_categories": frozenset()}),
    ):
        arm_capture()
        MESSAGES.clear()
        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            SecurityConfig(enable_redis=False, **cfg)
        logged = [m for m in MESSAGES]
        state = f"{len(logged)} logged" if logged else "nothing"
        print(f"    {label:42} -> {state}")
        for message in logged[:1]:
            print(f"      {message[:120]}")

    Verified on guard-core 3.14.0 / fastapi-guard 7.8.0, CPython 3.14.

  7. rennf93 commented on Aug 26, 2026

    @rennf93
    Member

    Thanks @RomanPszonka, you are right that the whitelist half is the odd one out, and re-reading the thread we conflated two different asks.

    What was ruled out on 2026-08-25, and stays ruled out, is a behaviour change: a /0 entry in whitelist means "allow every address", the whitelist takes precedence over the blacklist by design (documented in the whitelist field description and docs/internals/ip-management.md, pinned by tests since 3.2.0), and 0.0.0.0/0 is that rule at its logical extreme. Refusing or reinterpreting the entry would change what the operator wrote.

    What your comment asks for is different: a signal, the same one the other two items got in 3.14.0. On that we agree. A /0 whitelist entry silently turns the blacklist and the country rules off, and today it is the only one of the three that warns nothing. _is_prefix_zero_trusted_proxy_entry is the right test and is only wired to trusted_proxies.

    Planned for 3.14.1:

    • SecurityConfig logs a warning at construction when whitelist contains a prefix-0 network (0.0.0.0/0 or ::/0), naming the consequence: every address is whitelisted, so blacklist, blocked_countries and IP bans cannot block anyone. Same channel as the two 3.14.0 warnings (logger.warning on the guard_core logger, per construction), so validate_config in guard-core-mcp surfaces it as well.
    • Precedence and the resulting HTTP decisions are unchanged: your table stays 200 for both whitelist rows; the difference is the warning line.
    • Test and documentation updates alongside, in the same place the trusted_proxies warning is documented.

    This issue stays closed for the 3.14.0 items; the 3.14.1 change will reference it in the changelog.

  8. rennf93 commented on Aug 27, 2026

    @rennf93
    Member

    Shipped in guard-core 3.15.0 (PyPI), merged in #83 (75d1334).

    SecurityConfig now warns at construction when whitelist contains a prefix-0 network (0.0.0.0/0, ::/0), naming the consequence: every address is whitelisted, so blacklist, blocked_countries and IP bans cannot block anyone. Precedence is unchanged; your two whitelist rows still answer 200, now with the warning line. This closes the third of the three signals.

  9. rennf93 commented on Aug 27, 2026

    @rennf93
    Member

    @RomanPszonka 3.15.0 is out (minor, not 3.14.1: one change is labelled breaking). Yours in it: the /0 whitelist warning (this issue), the nested-JSON recursion fix (GHSA-f6cf-jjhc-qp85, published with the release), and the two residuals you called partially fixed: 3hfx now has a per-request character budget on top of the value count plus a 4x cheaper scan, and 8xvm's depth over-count is corrected (entries right of the selected one must be listed proxies, otherwise the chain is walked right to left) instead of only warned. The published 3hfx and 8xvm advisories carry a "Fixed in 3.15.0" section. Thanks again for the precision of the reports.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area: modelsTouches guard_core/models.pybugConfirmed defectdocumentationDocs, README, CHANGELOG, governance filessecuritySecurity-related issue (use private reporting first!)

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions