Skip to content

Commit 6f6e8d8

Browse files
authored
Merge branch 'dev-v2.0.36' into bugfix/autodev-2502-20261015135804529
2 parents 6de0e59 + db41eb5 commit 6f6e8d8

9 files changed

Lines changed: 418 additions & 23 deletions

File tree

‎src/memos/api/middleware/auth.py‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@
1717
import memos.log
1818

1919

20+
# Canonical format check; re-exported here so the middleware shares the
21+
# same logic as ``src/memos/api/utils/api_keys.py``. Keeping one source of
22+
# truth is what fixes issue #2427 (previously the middleware copy and the
23+
# utils copy could drift independently).
24+
from memos.api.utils.api_keys import validate_key_format as validate_key_format_canonical
25+
26+
2027
logger = memos.log.get_logger(__name__)
2128

2229
# API key header configuration
@@ -63,17 +70,16 @@ def hash_api_key(key: str) -> str:
6370

6471

6572
def validate_key_format(key: str) -> bool:
66-
"""Validate API key format: krlk_<64-hex>."""
67-
if not key or not key.startswith("krlk_"):
68-
return False
69-
hex_part = key[5:] # Remove 'krlk_' prefix
70-
if len(hex_part) != 64:
71-
return False
72-
try:
73-
int(hex_part, 16)
74-
return True
75-
except ValueError:
76-
return False
73+
"""Validate API key format: krlk_<64-hex>.
74+
75+
Re-exported from ``memos.api.utils.api_keys.validate_key_format`` for
76+
backwards compatibility with callers that imported this symbol from
77+
the middleware path. Issue #2427: the previous copy used ``int(...)``
78+
which accepted non-canonical hex (leading signs, ``0x`` prefix, ``_``
79+
separators, whitespace) — see the canonical implementation for the
80+
fix.
81+
"""
82+
return validate_key_format_canonical(key)
7783

7884

7985
def get_key_prefix(key: str) -> str:

‎src/memos/api/middleware/rate_limit.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,13 @@ def _get_client_key(request: Request) -> str:
5757
5858
Uses API key if available, otherwise falls back to IP.
5959
"""
60-
# Try to get API key from header
60+
# Try to get API key from header. Real clients send a Bearer scheme
61+
# per RFC 6750 (``Authorization: Bearer krlk_<hex>``); we strip the
62+
# scheme before the prefix check so keyed requests actually land on
63+
# a per-key bucket (issue #2428).
6164
auth_header = request.headers.get("Authorization", "")
65+
if auth_header.lower().startswith("bearer "):
66+
auth_header = auth_header[len("bearer ") :].lstrip()
6267
if auth_header.startswith("krlk_"):
6368
# Use first 20 chars of key as identifier
6469
return f"ratelimit:key:{auth_header[:20]}"

‎src/memos/api/utils/api_keys.py‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import hashlib
8+
import re
89
import secrets
910

1011
from dataclasses import dataclass
@@ -45,11 +46,19 @@ def hash_key(key: str) -> str:
4546
return hashlib.sha256(key.encode()).hexdigest()
4647

4748

49+
# Match the only form ``generate_api_key()`` produces: ``krlk_`` followed by
50+
# exactly 64 lowercase hex characters. Using ``int(hex_part, 16)`` would also
51+
# accept leading signs, ``0x`` prefixes, ``_`` separators, and surrounding
52+
# whitespace, none of which a real key ever carries (issue #2427).
53+
_KEY_FORMAT_HEX_RE = re.compile(r"[0-9a-f]{64}")
54+
55+
4856
def validate_key_format(key: str) -> bool:
4957
"""
5058
Validate API key format.
5159
52-
Valid format: krlk_<64-hex-chars>
60+
Valid format: ``krlk_<64-hex-chars>`` where the hex part is exactly 64
61+
characters from ``[0-9a-f]`` — the only form ``generate_api_key()`` produces.
5362
"""
5463
if not key or not isinstance(key, str):
5564
return False
@@ -58,14 +67,7 @@ def validate_key_format(key: str) -> bool:
5867
return False
5968

6069
hex_part = key[5:]
61-
if len(hex_part) != 64:
62-
return False
63-
64-
try:
65-
int(hex_part, 16)
66-
return True
67-
except ValueError:
68-
return False
70+
return _KEY_FORMAT_HEX_RE.fullmatch(hex_part) is not None
6971

7072

7173
def generate_master_key() -> tuple[str, str]:

‎src/memos/llms/openai_new.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def generate(self, messages: MessageList, **kwargs) -> str:
5050
output_reasoning = [
5151
item for item in response.output if isinstance(item, ResponseReasoningItem)
5252
]
53-
summary = output_reasoning[0].summary
53+
summary = output_reasoning[0].summary if output_reasoning else None
5454

5555
if self.config.remove_think_prefix:
5656
return remove_thinking_tags(output_text)
@@ -138,7 +138,7 @@ def generate(self, messages: MessageList, **kwargs) -> str:
138138
output_reasoning = [
139139
item for item in response.output if isinstance(item, ResponseReasoningItem)
140140
]
141-
summary = output_reasoning[0].summary
141+
summary = output_reasoning[0].summary if output_reasoning else None
142142

143143
if self.config.remove_think_prefix:
144144
return remove_thinking_tags(output_text)

‎src/memos/memories/textual/item.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,16 @@ class TextualMemoryMetadata(BaseModel):
159159
description="Internal algorithm metadata reserved for system use.",
160160
)
161161

162+
@field_validator("internal_info", mode="before")
163+
@classmethod
164+
def _decode_internal_info(cls, value: Any) -> Any:
165+
# Neo4j stores dictionaries as JSON strings; decode them before model validation.
166+
if isinstance(value, str):
167+
value = json.loads(value)
168+
if not isinstance(value, dict):
169+
raise ValueError("internal_info JSON must decode to an object")
170+
return value
171+
162172
model_config = ConfigDict(extra="allow")
163173

164174
covered_history: Any | None = Field(
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Regression tests for issue #2428.
2+
3+
``_get_client_key`` in ``src/memos/api/middleware/rate_limit.py`` checked
4+
whether ``request.headers['Authorization']`` started with ``krlk_`` — but
5+
real clients send ``Authorization: Bearer krlk_<hex>`` per RFC 6750, so
6+
the prefix check never matched and every request fell through to IP-based
7+
limiting. Behind a shared egress IP all keyed clients shared one bucket
8+
and the per-key rate-limit was dead code.
9+
10+
The fix strips the ``Bearer `` scheme before testing the key prefix, so
11+
``Bearer krlk_<hex>`` correctly maps to ``ratelimit:key:<first 20 of the
12+
stripped key>``.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from types import SimpleNamespace
18+
19+
from memos.api.middleware.rate_limit import _get_client_key
20+
21+
22+
def _fake_request(authorization: str | None, *, client_host: str = "10.0.0.1"):
23+
headers: dict[str, str] = {}
24+
if authorization is not None:
25+
headers["Authorization"] = authorization
26+
return SimpleNamespace(
27+
headers=headers,
28+
client=SimpleNamespace(host=client_host),
29+
)
30+
31+
32+
class TestGetClientKey:
33+
def test_bearer_krlk_key_uses_per_key_bucket(self):
34+
body = "krlk_" + "a" * 64
35+
request = _fake_request("Bearer " + body)
36+
key = _get_client_key(request)
37+
assert key == "ratelimit:key:" + body[:20]
38+
assert "10.0.0.1" not in key
39+
40+
def test_bearer_krlk_key_first_twenty_chars_of_actual_key(self):
41+
body = "krlk_" + "f" * 64
42+
request = _fake_request("Bearer " + body)
43+
key = _get_client_key(request)
44+
assert key == "ratelimit:key:" + body[:20]
45+
46+
def test_lowercase_bearer_scheme(self):
47+
# RFC 7235: the scheme name is case-insensitive. We normalise to
48+
# lowercase before the prefix check.
49+
body = "krlk_" + "b" * 64
50+
request = _fake_request("bearer " + body)
51+
key = _get_client_key(request)
52+
assert key == "ratelimit:key:" + body[:20]
53+
54+
def test_bearer_with_non_key_token_falls_back_to_ip(self):
55+
request = _fake_request("Bearer some-jwt-token")
56+
key = _get_client_key(request)
57+
assert key.startswith("ratelimit:ip:")
58+
assert "10.0.0.1" in key
59+
60+
def test_missing_authorization_header_falls_back_to_ip(self):
61+
request = _fake_request(None)
62+
key = _get_client_key(request)
63+
assert key.startswith("ratelimit:ip:")
64+
assert "10.0.0.1" in key
65+
66+
def test_x_forwarded_for_header_preferred_over_ip(self):
67+
request = _fake_request(None, client_host="203.0.113.1")
68+
request.headers["X-Forwarded-For"] = "198.51.100.1, 10.0.0.1"
69+
key = _get_client_key(request)
70+
assert key == "ratelimit:ip:198.51.100.1"
71+
72+
def test_krlk_prefix_without_bearer_scheme_still_matches(self):
73+
# Backwards compatibility: clients that sent a bare krlk_ header
74+
# (before the Bearer scheme was added) must still match.
75+
body = "krlk_" + "c" * 64
76+
request = _fake_request(body)
77+
key = _get_client_key(request)
78+
assert key == "ratelimit:key:" + body[:20]
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Regression tests for issue #2427.
2+
3+
``validate_key_format`` exists in two copies — ``src/memos/api/utils/api_keys.py``
4+
(the canonical home) and ``src/memos/api/middleware/auth.py`` (the
5+
middleware gating every authenticated request). Both check the hex part
6+
with ``int(hex_part, 16)``, which accepts non-canonical forms a generated
7+
key can never have:
8+
9+
- leading ``+`` / ``-`` (sign)
10+
- ``0x`` / ``0X`` prefix
11+
- ``_`` separators (PEP 515)
12+
- surrounding whitespace
13+
14+
So the documented format ``krlk_<64-hex-chars>`` is not actually enforced:
15+
keys with embedded underscores, signs, or ``0x`` prefixes pass the format
16+
gate and are hashed/looked up as-is, bypassing the canonical form that
17+
``generate_api_key()`` produces.
18+
19+
These tests pin the new contract on both validator copies:
20+
21+
- the hex part must match ``[0-9a-f]{64}`` exactly
22+
- the only valid form is the canonical lowercase hex string
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import pytest
28+
29+
from memos.api.middleware.auth import validate_key_format as validate_in_auth
30+
from memos.api.utils.api_keys import generate_api_key, validate_key_format
31+
32+
33+
CANONICAL = "krlk_" + "0123456789abcdef" * 4 # 64 hex chars, lowercase
34+
35+
36+
class TestValidateKeyFormatUtils:
37+
def test_canonical_key_passes(self):
38+
assert validate_key_format(CANONICAL) is True
39+
40+
def test_generated_key_always_passes(self):
41+
# The validator must accept every key that generate_api_key()
42+
# produces, since that is the documented format.
43+
for _ in range(20):
44+
assert validate_key_format(generate_api_key().key) is True
45+
46+
@pytest.mark.parametrize(
47+
"bad_key",
48+
[
49+
# Leading sign
50+
"krlk_" + "+" + "a" * 63,
51+
"krlk_" + "-" + "a" * 63,
52+
# 0x prefix
53+
"krlk_" + "0x" + "a" * 62,
54+
"krlk_" + "0X" + "a" * 62,
55+
# Underscore separator (PEP 515)
56+
"krlk_" + "a" * 32 + "_" + "b" * 31,
57+
# Uppercase hex (the generator emits lowercase only)
58+
"krlk_" + "A" * 64,
59+
# Surrounding whitespace
60+
" krlk_" + "a" * 64,
61+
"krlk_" + "a" * 64 + " ",
62+
# Wrong total length
63+
"krlk_" + "a" * 63,
64+
"krlk_" + "a" * 65,
65+
# Empty hex part
66+
"krlk_",
67+
# Missing prefix
68+
"0123456789abcdef" * 4,
69+
"Bearer " + CANONICAL,
70+
# Non-string
71+
"",
72+
],
73+
)
74+
def test_non_canonical_keys_rejected(self, bad_key):
75+
assert validate_key_format(bad_key) is False, (
76+
f"validate_key_format({bad_key!r}) should return False"
77+
)
78+
79+
80+
class TestAuthMiddlewareValidator:
81+
"""The middleware copy must enforce the same contract as the canonical
82+
one — issue #2427 documents both copies accepting non-canonical hex."""
83+
84+
def test_canonical_key_passes(self):
85+
assert validate_in_auth(CANONICAL) is True
86+
87+
def test_signed_hex_rejected(self):
88+
# The most surprising bit: ``int("+a..", 16)`` succeeds, so a signed
89+
# hex passes the middleware's validator today.
90+
assert validate_in_auth("krlk_" + "+" + "a" * 63) is False
91+
92+
def test_underscore_rejected(self):
93+
assert validate_in_auth("krlk_" + "a" * 32 + "_" + "b" * 31) is False
94+
95+
def test_uppercase_rejected(self):
96+
assert validate_in_auth("krlk_" + "A" * 64) is False
97+
98+
def test_0x_prefix_rejected(self):
99+
assert validate_in_auth("krlk_" + "0x" + "a" * 62) is False

0 commit comments

Comments
 (0)