Repository navigation
Description
Summary
In src/memos/api/middleware/auth.py, is_internal_request() treats every request as an
internal service call when INTERNAL_SERVICE_SECRET is not configured, because both sides of the
comparison are None.
Code
def is_internal_request(request: Request) -> bool:
"""Check if request is from internal service."""
client_host = request.client.host if request.client else None
# Check internal IPs
if client_host in INTERNAL_SERVICE_IPS:
return True
# Check internal header (for container-to-container)
internal_header = request.headers.get("X-Internal-Service")
return internal_header == os.getenv("INTERNAL_SERVICE_SECRET")If INTERNAL_SERVICE_SECRET is unset, os.getenv(...) returns None. If the client sends no
X-Internal-Service header, request.headers.get(...) also returns None. So the function
returns None == None → True.
Impact
verify_api_key() consults this before validating the key:
if is_internal_request(request):
return {"user_name": "internal", "scopes": ["all"], ...}So with AUTH_ENABLED=true but INTERNAL_SERVICE_SECRET unset, any request — including one with
no credentials at all — is accepted as user_name="internal" with scopes: ["all"]. Enabling
AUTH_ENABLED without also setting INTERNAL_SERVICE_SECRET provides no protection, which is
easy to do since nothing warns about it.
Reproduce
- Set
AUTH_ENABLED=true, leaveINTERNAL_SERVICE_SECRETunset. - Call any endpoint guarded by
Depends(verify_api_key)(e.g. inrouters/admin_router.py) from
a non-loopback address, with noAuthorizationand noX-Internal-Serviceheader. - The request is authorised as
internalwith all scopes.
Suggested fix
Require the secret to be present and non-empty before comparing, so an unset secret fails closed:
internal_secret = os.getenv("INTERNAL_SERVICE_SECRET")
if not internal_secret:
return False
return secrets.compare_digest(
request.headers.get("X-Internal-Service") or "", internal_secret
)Using secrets.compare_digest also avoids a timing side channel on the comparison.
It may also be worth logging a warning at startup when AUTH_ENABLED=true and
INTERNAL_SERVICE_SECRET is unset, since that combination currently looks secure but is not.
Note on INTERNAL_SERVICE_IPS
Separately, INTERNAL_SERVICE_IPS contains 127.0.0.1 / ::1, so any process that can reach the
server over loopback — a sidecar container, or a kubectl port-forward — is also authorised with
scopes: ["all"]. That may be intentional, but it is worth documenting, because loopback is not a
trust boundary in a shared pod or on a developer machine.
Related question
verify_api_key appears to be referenced only by routers/admin_router.py; server_api.py
includes server_router with no dependencies=[...], and its endpoints take user_id /
user_name / mem_cube_id as query or body parameters. Is the main memory API intended to be
unauthenticated and deployed on a private network only? If so, saying that explicitly in the
deployment docs would help — we had assumed cube isolation was enforced server-side per
authenticated caller, and it is not.
Version: a7367d07e55db61099f7b4e2c1108bc5831a24f3
Activity
Memtensor-AI commentedon Oct 31, 2026
🤖 AutoDev has picked up this issue and started working on it.
Task ID:
10c228629ce3a92aWorking branch:
bugfix/autodev-2553-20261031205634665Target branch:
latest dev* branchWorkflow: opsp (analysis → coding → testing → PR)
I will post the PR link here once done. If I need more information, I will ask in the comments.
fix(auth): fail closed in is_internal_request when secret unset (MemT…
Memtensor-AI commentedon Oct 31, 2026
✅ AutoDev task
10c228629ce3a92acompleted.Summary: Fixes issue #2553.
src/memos/api/middleware/auth.py::is_internal_request()previously compared theX-Internal-Serviceheader toos.getenv("INTERNAL_SERVICE_SECRET")with plain==. When operators enabledAUTH_ENABLED=truebut forgot to configure the secret, both sides collapsed toNone, so every anonymous external request was promoted touser_name="internal", scopes=["all"].The fix makes the header path fail closed: it now returns
FalsewheneverINTERNAL_SERVICE_SECRETis unset or empty, and usessecrets.compare_digeston the configured value to eliminate the timing side channel. A startup warning is logged at module import whenAUTH_ENABLED=trueis combined with an unset secret, so the misconfiguration surfaces instead of pretending to work. TheINTERNAL_SERVICE_IPSloopback short-circuit is deliberately left untouched (the issue flags it as a separate discussion).Tests: a new regression suite
tests/api/test_is_internal_request.pypins the contract — unset/empty secret always rejects (with and without a header value); configured secret authenticates only on exact match; loopback IPs still short-circuit; the end-to-endverify_api_keypath returns 401 for anonymous external callers. Verification:pytest tests/api/test_is_internal_request.py tests/api/test_validate_key_format.py -q→ 33 passed.ruff check src/memos/api/middleware/auth.py tests/api/test_is_internal_request.py→ all checks passed.ruff formatapplied.No API surface, OpenAPI, or dependency changes. Branch pushed to origin; specs archive synced to memos-autodev-specs.
Base branch:
mainBranch:
bugfix/autodev-2553-20261031205634665Commit:
b648341PR: #2554
Assigned to: @hijzy
Reviewers: @bittergreen