hermes - 💡(How to fix) Fix [Bug]: _global_allow_private_urls() sets _allow_private_resolved=True before computing the cache value — concurrent callers see stale False

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

_global_allow_private_urls() in tools/url_safety.py has a TOCTOU (time-of-check/time-of-use) race in its lazy-init pattern. The resolver flag _allow_private_resolved is set to True before _cached_allow_private is fully computed, so a concurrent caller can observe _allow_private_resolved = True while _cached_allow_private is still the stale default False.

Root Cause

# tools/url_safety.py
_allow_private_resolved = False
_cached_allow_private: bool = False

def _global_allow_private_urls() -> bool:
    global _allow_private_resolved, _cached_allow_private
    if _allow_private_resolved:
        return _cached_allow_private    # ← fast path: returns _cached_allow_private

    _allow_private_resolved = True       # ← published as "done" immediately
    _cached_allow_private = False        # safe default
    # ... config reads happen here (slow path) ...
    _cached_allow_private = True         # ← may still be running when another thread hits the fast path
    return _cached_allow_private

Timeline:

  1. Thread A: _allow_private_resolved = False → enters body
  2. Thread A: _allow_private_resolved = True (line 94)
  3. Thread B: _allow_private_resolved = True → fast path → returns _cached_allow_private = False (stale!)
  4. Thread A: finishes computing, sets _cached_allow_private = True

Thread B returns False when config says True — the user's allow_private_urls setting is silently ignored for that call.

Fix Action

Fix

Add _allow_private_lock = threading.Lock() and use double-checked locking so the cache is fully computed before the resolved flag is published:

_allow_private_lock = threading.Lock()

def _global_allow_private_urls() -> bool:
    if _allow_private_resolved:
        return _cached_allow_private               # fast path — no lock needed

    with _allow_private_lock:
        if _allow_private_resolved:
            return _cached_allow_private           # another thread beat us

        # Compute result before publishing resolved flag.
        ...                                        # existing config logic
        _cached_allow_private = result             # set value first
        _allow_private_resolved = True             # publish last
        return _cached_allow_private

Code Example

# tools/url_safety.py
_allow_private_resolved = False
_cached_allow_private: bool = False

def _global_allow_private_urls() -> bool:
    global _allow_private_resolved, _cached_allow_private
    if _allow_private_resolved:
        return _cached_allow_private    # ← fast path: returns _cached_allow_private

    _allow_private_resolved = True       # ← published as "done" immediately
    _cached_allow_private = False        # safe default
    # ... config reads happen here (slow path) ...
    _cached_allow_private = True         # ← may still be running when another thread hits the fast path
    return _cached_allow_private

---

_allow_private_lock = threading.Lock()

def _global_allow_private_urls() -> bool:
    if _allow_private_resolved:
        return _cached_allow_private               # fast path — no lock needed

    with _allow_private_lock:
        if _allow_private_resolved:
            return _cached_allow_private           # another thread beat us

        # Compute result before publishing resolved flag.
        ...                                        # existing config logic
        _cached_allow_private = result             # set value first
        _allow_private_resolved = True             # publish last
        return _cached_allow_private
RAW_BUFFERClick to expand / collapse

Description

_global_allow_private_urls() in tools/url_safety.py has a TOCTOU (time-of-check/time-of-use) race in its lazy-init pattern. The resolver flag _allow_private_resolved is set to True before _cached_allow_private is fully computed, so a concurrent caller can observe _allow_private_resolved = True while _cached_allow_private is still the stale default False.

Root cause

# tools/url_safety.py
_allow_private_resolved = False
_cached_allow_private: bool = False

def _global_allow_private_urls() -> bool:
    global _allow_private_resolved, _cached_allow_private
    if _allow_private_resolved:
        return _cached_allow_private    # ← fast path: returns _cached_allow_private

    _allow_private_resolved = True       # ← published as "done" immediately
    _cached_allow_private = False        # safe default
    # ... config reads happen here (slow path) ...
    _cached_allow_private = True         # ← may still be running when another thread hits the fast path
    return _cached_allow_private

Timeline:

  1. Thread A: _allow_private_resolved = False → enters body
  2. Thread A: _allow_private_resolved = True (line 94)
  3. Thread B: _allow_private_resolved = True → fast path → returns _cached_allow_private = False (stale!)
  4. Thread A: finishes computing, sets _cached_allow_private = True

Thread B returns False when config says True — the user's allow_private_urls setting is silently ignored for that call.

Impact

  • _global_allow_private_urls() controls SSRF protection in _is_url_safe() (line 275). When the race fires, a URL that the user explicitly allowed via config is blocked for the duration of that concurrent call.
  • The race window is small (only at process start) but real in gateway mode where multiple requests arrive concurrently.
  • The failure mode is false-deny (safe, but incorrect against the user's intent).

Fix

Add _allow_private_lock = threading.Lock() and use double-checked locking so the cache is fully computed before the resolved flag is published:

_allow_private_lock = threading.Lock()

def _global_allow_private_urls() -> bool:
    if _allow_private_resolved:
        return _cached_allow_private               # fast path — no lock needed

    with _allow_private_lock:
        if _allow_private_resolved:
            return _cached_allow_private           # another thread beat us

        # Compute result before publishing resolved flag.
        ...                                        # existing config logic
        _cached_allow_private = result             # set value first
        _allow_private_resolved = True             # publish last
        return _cached_allow_private

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING