Spaces:
Running on Zero
fix(gate): accept x-ael-key alongside x-respite-key; drop dead key read
Browse filesThe Respite -> Ael rename renamed the app's copy of the shared-key header to
X-Ael-Key while this gate kept reading x-respite-key. Those are different
header *names*, so every protected request was rejected with 401 from a
perfectly correct key — search and audio, on every deployment, silently.
Header names are case-insensitive, so the rename looked harmless in review;
it was not. c9fb4ed already restarted the Space so SEARX_API_KEY is loaded,
which is why the only remaining fault was the header name.
- Accept either spelling, x-respite-key first as canonical. The app now sends
the canonical one; the alias keeps any build in flight during a rename from
401ing, and must not be dropped without checking deployed builds.
- Drop the module-level _SPACE_KEY read of "RESpite_SPACE_KEY" — a
case-mangled secret name that never existed and always read "". It was masked
because the middleware rebinds the same name per request, so the broken read
was dead code one deleted line away from every gated route answering 503.
- Extract _is_protected() so the prefix check exists once. str.startswith takes
a tuple here; an equivalent transcription written with an array silently
matches nothing, which is exactly how the first draft of the app-side test
passed while testing nothing.
|
@@ -901,10 +901,19 @@ def _patched_create_app(blocks, **kwargs):
|
|
| 901 |
try:
|
| 902 |
import hmac as _hmac
|
| 903 |
|
| 904 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 905 |
# Which secret guards which prefix. The searx bridge gets its own key
|
| 906 |
# (SEARX_API_KEY) so rotating search access never touches the audio
|
| 907 |
-
# path; everything else shares
|
| 908 |
_PROTECTED_PREFIXES = (
|
| 909 |
"/respite/audio/",
|
| 910 |
"/respite/search",
|
|
@@ -921,15 +930,36 @@ def _patched_create_app(blocks, **kwargs):
|
|
| 921 |
return os.environ.get("SEARX_API_KEY", "")
|
| 922 |
return os.environ.get("RESPIRE_SPACE_KEY", "")
|
| 923 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 924 |
@fa_app.middleware("http")
|
| 925 |
async def _respite_key_gate(request, call_next):
|
| 926 |
-
if request.url.path
|
| 927 |
_SPACE_KEY = _expected_key(request.url.path)
|
| 928 |
if not _SPACE_KEY:
|
| 929 |
return JSONResponse(
|
| 930 |
{"error": "service not configured"}, status_code=503
|
| 931 |
)
|
| 932 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 933 |
if not _hmac.compare_digest(presented, _SPACE_KEY):
|
| 934 |
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
| 935 |
return await call_next(request)
|
|
|
|
| 901 |
try:
|
| 902 |
import hmac as _hmac
|
| 903 |
|
| 904 |
+
# NOTE: there used to be a module-level
|
| 905 |
+
# _SPACE_KEY = os.environ.get("RESpite_SPACE_KEY", "")
|
| 906 |
+
# here — a case-mangled name left by the Respite -> Ael rename, reading
|
| 907 |
+
# a secret that does not exist, so it always yielded "". It looked
|
| 908 |
+
# harmless because the middleware rebinds `_SPACE_KEY` from
|
| 909 |
+
# `_expected_key(path)` on every protected request, making the broken
|
| 910 |
+
# read dead. It was one deleted line away from every gated route
|
| 911 |
+
# answering 503 "service not configured" with nothing to say why. The key
|
| 912 |
+
# is now read in exactly one place.
|
| 913 |
+
#
|
| 914 |
# Which secret guards which prefix. The searx bridge gets its own key
|
| 915 |
# (SEARX_API_KEY) so rotating search access never touches the audio
|
| 916 |
+
# path; everything else shares RESPITE_SPACE_KEY.
|
| 917 |
_PROTECTED_PREFIXES = (
|
| 918 |
"/respite/audio/",
|
| 919 |
"/respite/search",
|
|
|
|
| 930 |
return os.environ.get("SEARX_API_KEY", "")
|
| 931 |
return os.environ.get("RESPIRE_SPACE_KEY", "")
|
| 932 |
|
| 933 |
+
# The header the shared key is presented in. `x-respite-key` is
|
| 934 |
+
# canonical and is what the app sends; `x-ael-key` is accepted as an
|
| 935 |
+
# alias because the app was renamed Respite -> Ael and briefly sent
|
| 936 |
+
# `X-Ael-Key`, a header this gate never read — so every search and audio
|
| 937 |
+
# call 401'd for days. Header names are case-insensitive, so the alias
|
| 938 |
+
# is the same token in lower case; the real fault was that `x-ael-key`
|
| 939 |
+
# and `x-respite-key` are different *names* and the two sides drifted.
|
| 940 |
+
# Accepting both keeps any build in flight during a rename working. Do
|
| 941 |
+
# not drop the alias without checking that no deployed build still sends
|
| 942 |
+
# it.
|
| 943 |
+
_KEY_HEADERS = ("x-respite-key", "x-ael-key")
|
| 944 |
+
|
| 945 |
+
def _is_protected(path: str) -> bool:
|
| 946 |
+
return any(path.startswith(p) for p in _PROTECTED_PREFIXES)
|
| 947 |
+
|
| 948 |
@fa_app.middleware("http")
|
| 949 |
async def _respite_key_gate(request, call_next):
|
| 950 |
+
if _is_protected(request.url.path):
|
| 951 |
_SPACE_KEY = _expected_key(request.url.path)
|
| 952 |
if not _SPACE_KEY:
|
| 953 |
return JSONResponse(
|
| 954 |
{"error": "service not configured"}, status_code=503
|
| 955 |
)
|
| 956 |
+
# Either spelling satisfies the gate; the first present wins.
|
| 957 |
+
presented = ""
|
| 958 |
+
for _h in _KEY_HEADERS:
|
| 959 |
+
_v = request.headers.get(_h)
|
| 960 |
+
if _v:
|
| 961 |
+
presented = _v
|
| 962 |
+
break
|
| 963 |
if not _hmac.compare_digest(presented, _SPACE_KEY):
|
| 964 |
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
| 965 |
return await call_next(request)
|