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
5 changes: 5 additions & 0 deletions changelog.d/384.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
**The session examples no longer fall back to a fixed placeholder key.** With
`SESSION_SECRET_KEY` unset they emitted valid sessions signed with a key
published in the repository, so a copied example that reached production
without the variable accepted forged tokens. They now warn and sign with a
random key made up for that run.
7 changes: 7 additions & 0 deletions changelog.d/386.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
**Clearer warnings.** The `FutureWarning` from `get_session_manager()`
now says whether `SessionManagerProxy` is empty or holds a different manager,
the `UserWarning` for a `__Host-`/`__Secure-` cookie name the browser would
refuse links to the 0.4.0 issue, and the Memcached `RuntimeWarning`s of
`clear()`, `clear_path()`, `clear_pattern()` and `get_all_keys()` name the
application's line when raised through `CacheManager` or `SessionManager`,
rather than a line in the library.
8 changes: 5 additions & 3 deletions docs/APP_CACHE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,15 @@ Complete runnable example: [`examples/app_cache.py`](https://github.com/allen009
first time it is used; `CacheManagerProxy.set()` registers your own instead.

> [!NOTE]
> `clear()`/`clear_prefix()` are implemented via the backend's `get_all_keys()`
> `CacheManager.clear()`/`clear_prefix()` are implemented via the backend's `get_all_keys()`
> and `delete_many()` (`DEL` in batches of 100 keys on Redis). Since Memcached doesn't
> support key enumeration (see [Backends](BACKENDS.md#memcached)), these
> methods — and `clear_pattern()` — are no-ops on a Memcached backend that
> methods — and `CacheManager.clear_pattern()` — are no-ops on a Memcached backend that
> return 0 with a `RuntimeWarning`;
> `get()`/`set()`/`add()`/`delete()`/`has()` work normally. Use Redis or the in-memory
> backend if you need bulk clearing.
> backend if you need bulk clearing. Do not fall back to the backend's own `clear()`
> on Memcached: `MemcachedBackend.clear()` issues `flush_all` and wipes the whole
> server, HTTP responses, sessions, locks and other applications' keys included.

## Stampede protection

Expand Down
7 changes: 6 additions & 1 deletion docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ BackendProxy.set(backend)
- Uses SCAN instead of KEYS for safe production use (non-blocking)
- Namespaced with `fastapi_cachex:` prefix by default; pass `key_prefix="myapp:cache:"`
for multi-tenant scenarios
- `clear()`, `clear_pattern()` and `clear_path()` delete only keys under this backend's
`key_prefix`; other applications on the same server keep their keys
- Only the pattern you pass to `clear_pattern()` is a glob. The key prefix and the path
given to `clear_path()` are matched literally, so `*`, `?`, `[` or `]` in them cannot
reach keys outside the prefix or miss the path
Expand Down Expand Up @@ -185,7 +187,10 @@ BackendProxy.set(backend)
To drop a cached route's entry after a write, call
[`invalidate(request)`](HTTP_CACHING.md#invalidating-a-single-cached-route), which
rebuilds the exact key
- `clear()` issues `flush_all`, which wipes the whole Memcached server, not just this namespace
- `backend.clear()` (`MemcachedBackend.clear()`) issues `flush_all`, which wipes the whole
Memcached server, not just this namespace. `CacheManager.clear()` is different: it
enumerates keys, so on Memcached it deletes nothing (see
[Application cache](APP_CACHE.md))
- A key Memcached would reject (over 250 bytes, whitespace, non-ASCII) is stored
under its SHA-256 digest
- A `ttl` whose expiry falls after 2038-01-19 raises `ValueError` (see
Expand Down
9 changes: 5 additions & 4 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ In your own project, install the extras an example needs, for example

## Secrets

The session examples read their signing key from `SESSION_SECRET_KEY` and fall
back to an obvious development placeholder. The monitoring routes in
`http_cache.py` stay closed until `CACHE_ADMIN_TOKEN` is set. Always set real,
random values outside local development:
The session examples read their signing key from `SESSION_SECRET_KEY`. When it
is unset they warn and sign with a random key made up for that run, so sessions
end when the process restarts and are not shared between workers. The
monitoring routes in `http_cache.py` stay closed until `CACHE_ADMIN_TOKEN` is
set. Always set real, random values outside local development:

```bash
python -c "import secrets; print(secrets.token_urlsafe(48))"
Expand Down
26 changes: 21 additions & 5 deletions examples/session_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import os
import secrets
import warnings
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

Expand All @@ -35,12 +36,27 @@
backend = MemoryBackend()
BackendProxy.set(backend)


def session_secret_key() -> str:
"""Return SESSION_SECRET_KEY, or a random key for this run with a warning."""
key = os.environ.get("SESSION_SECRET_KEY")
if key:
return key
warnings.warn(
"SESSION_SECRET_KEY is not set, so this run signs sessions with a "
"random key: they end when the process restarts and are not shared "
"between workers. Set SESSION_SECRET_KEY to a random value of at least "
"32 characters, e.g. the output of "
'`python -c "import secrets; print(secrets.token_urlsafe(48))"`.',
UserWarning,
stacklevel=2,
)
return secrets.token_urlsafe(48)


config = SessionConfig(
# At least 32 characters. Set a real random value in production, e.g.
# `python -c "import secrets; print(secrets.token_urlsafe(48))"`.
secret_key=os.environ.get(
"SESSION_SECRET_KEY", "dev-only-placeholder-change-me-before-deploying"
),
# At least 32 characters, from the environment; see session_secret_key().
secret_key=session_secret_key(),
session_ttl=3600, # 1 hour
# Both cookie defaults change in 0.4.0, so set them explicitly. Over HTTPS
# use cookie_name="__Host-session", cookie_https_only=True.
Expand Down
26 changes: 21 additions & 5 deletions examples/session_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import os
import secrets
import warnings
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

Expand All @@ -33,13 +34,28 @@
backend = MemoryBackend()
BackendProxy.set(backend)


def session_secret_key() -> str:
"""Return SESSION_SECRET_KEY, or a random key for this run with a warning."""
key = os.environ.get("SESSION_SECRET_KEY")
if key:
return key
warnings.warn(
"SESSION_SECRET_KEY is not set, so this run signs sessions with a "
"random key: they end when the process restarts and are not shared "
"between workers. Set SESSION_SECRET_KEY to a random value of at least "
"32 characters, e.g. the output of "
'`python -c "import secrets; print(secrets.token_urlsafe(48))"`.',
UserWarning,
stacklevel=2,
)
return secrets.token_urlsafe(48)


config = SessionConfig(
# HS256 wants a key of at least 32 bytes (HS384: 48, HS512: 64), or
# SessionManager warns. Set a real random value in production, e.g.
# `python -c "import secrets; print(secrets.token_urlsafe(48))"`.
secret_key=os.environ.get(
"SESSION_SECRET_KEY", "dev-only-placeholder-change-me-before-deploying"
),
# SessionManager warns; see session_secret_key().
secret_key=session_secret_key(),
token_format="jwt",
jwt_algorithm="HS256",
# Optional: issued as `iss`/`aud` and checked on every request.
Expand Down
28 changes: 22 additions & 6 deletions examples/session_jwt_claims.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
# --8<-- [start:serializer]
import os
import secrets
import warnings
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import datetime
Expand Down Expand Up @@ -130,15 +131,30 @@ def check_claims(self, payload: dict[str, Any]) -> None:

# --8<-- [end:multi-tenant]


# --8<-- [start:setup]
def session_secret_key() -> str:
"""Return SESSION_SECRET_KEY, or a random key for this run with a warning."""
key = os.environ.get("SESSION_SECRET_KEY")
if key:
return key
warnings.warn(
"SESSION_SECRET_KEY is not set, so this run signs sessions with a "
"random key: they end when the process restarts and are not shared "
"between workers. Set SESSION_SECRET_KEY to a random value of at least "
"32 characters, e.g. the output of "
'`python -c "import secrets; print(secrets.token_urlsafe(48))"`.',
UserWarning,
stacklevel=2,
)
return secrets.token_urlsafe(48)


backend = MemoryBackend()
config = SessionConfig(
# HS256 wants a key of at least 32 bytes (HS384: 48, HS512: 64). Set a real
# random value in production, e.g.
# `python -c "import secrets; print(secrets.token_urlsafe(48))"`.
secret_key=os.environ.get(
"SESSION_SECRET_KEY", "dev-only-placeholder-change-me-before-deploying"
),
# HS256 wants a key of at least 32 bytes (HS384: 48, HS512: 64); see
# session_secret_key().
secret_key=session_secret_key(),
token_format="jwt",
jwt_algorithm="HS256",
jwt_issuer="acme-corp",
Expand Down
26 changes: 21 additions & 5 deletions examples/session_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import os
import secrets
import warnings
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

Expand All @@ -36,12 +37,27 @@
backend = MemoryBackend()
BackendProxy.set(backend)


def session_secret_key() -> str:
"""Return SESSION_SECRET_KEY, or a random key for this run with a warning."""
key = os.environ.get("SESSION_SECRET_KEY")
if key:
return key
warnings.warn(
"SESSION_SECRET_KEY is not set, so this run signs sessions with a "
"random key: they end when the process restarts and are not shared "
"between workers. Set SESSION_SECRET_KEY to a random value of at least "
"32 characters, e.g. the output of "
'`python -c "import secrets; print(secrets.token_urlsafe(48))"`.',
UserWarning,
stacklevel=2,
)
return secrets.token_urlsafe(48)


config = SessionConfig(
# At least 32 characters. Set a real random value in production, e.g.
# `python -c "import secrets; print(secrets.token_urlsafe(48))"`.
secret_key=os.environ.get(
"SESSION_SECRET_KEY", "dev-only-placeholder-change-me-before-deploying"
),
# At least 32 characters, from the environment; see session_secret_key().
secret_key=session_secret_key(),
session_ttl=3600,
# 0.4.0 changes both cookie defaults (to "__Host-session" with the Secure
# flag), so set them explicitly. In production over HTTPS use
Expand Down
26 changes: 21 additions & 5 deletions examples/session_redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import os
import secrets
import warnings
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import datetime
Expand Down Expand Up @@ -46,12 +47,27 @@
key_prefix="fastapi_cachex_example:",
)


def session_secret_key() -> str:
"""Return SESSION_SECRET_KEY, or a random key for this run with a warning."""
key = os.environ.get("SESSION_SECRET_KEY")
if key:
return key
warnings.warn(
"SESSION_SECRET_KEY is not set, so this run signs sessions with a "
"random key: they end when the process restarts and are not shared "
"between workers. Set SESSION_SECRET_KEY to a random value of at least "
"32 characters, e.g. the output of "
'`python -c "import secrets; print(secrets.token_urlsafe(48))"`.',
UserWarning,
stacklevel=2,
)
return secrets.token_urlsafe(48)


config = SessionConfig(
# At least 32 characters. Set a real random value in production, e.g.
# `python -c "import secrets; print(secrets.token_urlsafe(48))"`.
secret_key=os.environ.get(
"SESSION_SECRET_KEY", "dev-only-placeholder-change-me-before-deploying"
),
# At least 32 characters, from the environment; see session_secret_key().
secret_key=session_secret_key(),
session_ttl=3600,
sliding_expiration=True,
sliding_threshold=0.5,
Expand Down
31 changes: 27 additions & 4 deletions fastapi_cachex/backends/memcached.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import hashlib
import inspect
import logging
import time
import warnings
Expand Down Expand Up @@ -68,6 +69,26 @@ def _expiry(ttl: int | None) -> int:
return ttl


def _caller_stacklevel() -> int:
"""Return the ``stacklevel`` of the first frame outside fastapi_cachex.

For a ``warnings.warn`` in the method that calls this, so a warning raised
through ``CacheManager`` names the application's line, not manager.py.
"""
frame = inspect.currentframe()
if frame is None or frame.f_back is None: # no frame support
return 2
# Level 1 is the method that warns; start at its caller.
level, frame = 2, frame.f_back.f_back
while frame is not None:
module = frame.f_globals.get("__name__", "")
if module != "fastapi_cachex" and not module.startswith("fastapi_cachex."):
break
frame = frame.f_back
level += 1
return level


class MemcachedBackend(BaseCacheBackend):
"""Memcached backend implementation.

Expand Down Expand Up @@ -426,7 +447,7 @@ async def clear(self) -> None:
"this namespace cannot be cleared on its own; delete known keys "
"with delete() or delete_many() instead.",
RuntimeWarning,
stacklevel=2,
stacklevel=_caller_stacklevel(),
)
await asyncio.to_thread(self.client.flush_all)
logger.debug("Memcached CLEAR; flush_all issued")
Expand Down Expand Up @@ -456,7 +477,7 @@ async def clear_path(self, path: str, include_params: bool = False) -> int:
"exactly as the path, and include_params has no effect. Use "
"invalidate(request) to drop a cached route's entry.",
RuntimeWarning,
stacklevel=2,
stacklevel=_caller_stacklevel(),
)

# Try to delete the prefixed key (exact match only)
Expand Down Expand Up @@ -490,7 +511,7 @@ async def clear_pattern(self, pattern: str) -> int:
"Consider using Redis backend for pattern support, "
"or track keys manually in your application logic.",
RuntimeWarning,
stacklevel=2,
stacklevel=_caller_stacklevel(),
)
logger.debug("Memcached CLEAR_PATTERN unsupported; pattern=%s", pattern)
return 0
Expand All @@ -512,7 +533,7 @@ async def get_all_keys(self) -> list[str]:
"Consider using Redis backend if you need cache monitoring, "
"or track keys manually in your application.",
RuntimeWarning,
stacklevel=2,
stacklevel=_caller_stacklevel(),
)
logger.debug("Memcached GET_ALL_KEYS unsupported; returning empty list")
return []
Expand All @@ -531,6 +552,8 @@ async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]:
"get_cache_data() returns an empty dictionary. "
"Consider using Redis backend if you need cache monitoring.",
RuntimeWarning,
# Called by the monitoring route, whose caller is FastAPI itself:
# routes.py names the source better than any frame outside it.
stacklevel=2,
)
logger.debug("Memcached GET_CACHE_DATA unsupported; returning empty dict")
Expand Down
3 changes: 2 additions & 1 deletion fastapi_cachex/session/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,8 @@ def _warn_invalid_cookie_prefix(self) -> "SessionConfig":
f"cookie_name={self.cookie_name!r} requires {', '.join(problems)}: "
"browsers refuse a cookie with this prefix otherwise, so the "
"session cookie would never be stored. Version 0.4.0 will reject "
"this configuration.",
"this configuration "
"(https://github.com/allen0099/FastAPI-CacheX/issues/256).",
UserWarning,
stacklevel=3,
)
Expand Down
15 changes: 10 additions & 5 deletions fastapi_cachex/session/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,14 +176,19 @@ async def login(
"FastAPICacheXSessionMiddleware is added to the app."
),
)
if not getattr(state, _PROXY_WARNED, False) and manager is not _proxy_manager():
proxy_manager = _proxy_manager()
if not getattr(state, _PROXY_WARNED, False) and manager is not proxy_manager:
setattr(state, _PROXY_WARNED, True)
registered = (
"no SessionManager is set in SessionManagerProxy"
if proxy_manager is None
else "a different SessionManager is set in SessionManagerProxy"
)
warnings.warn(
"get_session_manager() returned the SessionManager the session "
"middleware registered, which is not the one set in "
"SessionManagerProxy. Version 0.4.0 resolves get_session_manager() "
"(and SessionManagerDep, ClientIPDep and rotate_session_id(), which use "
"it) through SessionManagerProxy "
f"middleware registered, but {registered}. Version 0.4.0 resolves "
"get_session_manager() (and SessionManagerDep, ClientIPDep and "
"rotate_session_id(), which use it) through SessionManagerProxy "
"only. Call SessionManagerProxy.set(session_manager) at startup "
"(https://github.com/allen0099/FastAPI-CacheX/issues/131).",
FutureWarning,
Expand Down
Loading
Loading