Skip to content

Commit 715daff

Browse files
committed
Distinguish corrupt vs transient errors in LoadAuth.get_tok
When the eauth token backend raised any exception, the previous code either propagated it or treated it as "remove the token". For Redis or other network-backed token stores, a transient backend error (connection drop, NFS hang, full disk) would therefore log every authenticated user out -- ``rm_token`` deletes valid tokens that just briefly could not be read. Split the ``except`` into two cases: * ``SaltDeserializationError`` -- the on-disk blob is corrupt and cannot be parsed. The token is permanently unusable, so removing it is correct: leaving it around would make every subsequent ``get_tok`` for the same id keep failing. * ``OSError`` (covers ``IOError``) -- transient backend error. The token itself is fine; do NOT delete it. Return ``{}`` so the caller treats this request as not-authenticated, and the next request retries against the backend. Three behavioural tests guard each branch and the existing expired path. The IOError test fails on the previous implementation, which is the regression this change exists to prevent. Refs: #69073
1 parent b8871ff commit 715daff

3 files changed

Lines changed: 132 additions & 15 deletions

File tree

changelog/69073.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
``LoadAuth.get_tok`` now distinguishes between corrupt token blobs (removed from the store) and transient backend errors such as Redis connection drops or NFS hangs (token kept, request treated as not-authenticated). Previously a single backend hiccup could log every authenticated user out by deleting valid tokens.

salt/auth/__init__.py

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -239,30 +239,45 @@ def get_tok(self, tok):
239239
Return the name associated with the token, or False if the token is
240240
not valid
241241
"""
242-
tdata = {}
243242
try:
244243
tdata = self.tokens["{}.get_token".format(self.opts["eauth_tokens"])](
245244
self.opts, tok
246245
)
247246
except salt.exceptions.SaltDeserializationError:
248-
log.warning("Failed to load token %r - removing broken/empty file.", tok)
249-
rm_tok = True
250-
else:
251-
if not tdata:
252-
return {}
253-
rm_tok = False
247+
# The on-disk / in-store token blob is corrupt and cannot
248+
# be parsed. Removing it is the right call -- a corrupt
249+
# token can never authenticate anyway, and leaving it
250+
# around makes every subsequent ``get_tok`` for the same
251+
# id keep failing.
252+
log.warning(
253+
"Token %r could not be deserialized; removing it from the " "store.",
254+
tok,
255+
)
256+
self.rm_token(tok)
257+
return {}
258+
except OSError:
259+
# Transient backend error (Redis connection blip, NFS hang,
260+
# hung disk). The token itself is fine; do NOT delete it --
261+
# that would log every authenticated user out on every
262+
# backend hiccup. Return an empty dict so the caller treats
263+
# this request as not-authenticated; the next request will
264+
# retry against the backend and succeed once it recovers.
265+
log.warning(
266+
"Token store transient error reading %r; treating as "
267+
"not-authenticated for this request without removing the "
268+
"token from the store.",
269+
tok,
270+
)
271+
return {}
254272

273+
if not tdata:
274+
return {}
255275
if tdata.get("expire", 0) < time.time():
256-
# If expire isn't present in the token it's invalid and needs
257-
# to be removed. Also, if it's present and has expired - in
258-
# other words, the expiration is before right now, it should
259-
# be removed.
260-
rm_tok = True
261-
262-
if rm_tok:
276+
# Expired token: drop it from the store. ``expire`` defaults
277+
# to 0 if missing, so a malformed-but-deserializable token
278+
# without an ``expire`` key falls into this branch too.
263279
self.rm_token(tok)
264280
return {}
265-
266281
return tdata
267282

268283
def list_tokens(self):

tests/pytests/unit/auth/test_auth.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import salt.auth
44
import salt.config
5+
import salt.exceptions
6+
from tests.support.mock import MagicMock
57

68

79
def test_cve_2021_3244(tmp_path):
@@ -31,3 +33,102 @@ def test_cve_2021_3244(tmp_path):
3133
t_data = auth.get_tok(t_data["token"])
3234
assert not t_data
3335
assert not token_file.exists()
36+
37+
38+
# ---------------------------------------------------------------------------
39+
# ``LoadAuth.get_tok`` exception handling.
40+
#
41+
# A backend read failure must be classified by *cause*, not collapsed to
42+
# "delete the token":
43+
#
44+
# * ``SaltDeserializationError`` — the stored blob cannot be parsed. The
45+
# token is permanently unusable; remove it so subsequent reads do not
46+
# keep failing on the same corrupt entry.
47+
# * ``OSError`` / ``IOError`` — transient (Redis connection drop, NFS
48+
# hang, full disk). The token itself is fine. Returning ``{}`` while
49+
# leaving the token in place makes this request not-authenticated and
50+
# lets the next request retry once the backend recovers. **Deleting on
51+
# transient I/O errors logs every authenticated user out on every
52+
# backend hiccup**, which is the regression these tests guard against.
53+
# * Expired (deserialized successfully but past ``expire``) — remove.
54+
# ---------------------------------------------------------------------------
55+
56+
57+
def _make_auth(tmp_path, get_token_side_effect):
58+
"""Build a ``LoadAuth`` whose ``localfs.get_token`` is replaced by
59+
``get_token_side_effect``. ``rm_token`` is wrapped with a
60+
``MagicMock`` so the test can assert on whether the production code
61+
chose to delete the token. The backing token directory is real so
62+
``rm_token`` would not blow up if it were called -- we are checking
63+
*intent*, not state."""
64+
token_dir = tmp_path / "tokens"
65+
token_dir.mkdir()
66+
opts = {
67+
"extension_modules": "",
68+
"optimization_order": [0, 1, 2],
69+
"token_expire": 60,
70+
"keep_acl_in_token": False,
71+
"eauth_tokens": "localfs",
72+
"token_dir": str(token_dir),
73+
"token_expire_user_override": False,
74+
"external_auth": {"auto": {"foo": []}},
75+
}
76+
auth = salt.auth.LoadAuth(opts)
77+
78+
if callable(get_token_side_effect):
79+
auth.tokens["localfs.get_token"] = get_token_side_effect
80+
else:
81+
auth.tokens["localfs.get_token"] = MagicMock(side_effect=get_token_side_effect)
82+
83+
auth.tokens["localfs.rm_token"] = MagicMock()
84+
return auth
85+
86+
87+
def test_get_tok_returns_empty_and_keeps_token_on_io_error(tmp_path):
88+
"""Headline regression: a transient backend error (e.g. Redis
89+
connection drop) must NOT cause the token to be deleted. The
90+
previous implementation either propagated the exception or deleted
91+
the token -- both wrong. Correct behaviour is to return ``{}`` and
92+
leave the token alone so the next request can retry."""
93+
auth = _make_auth(tmp_path, OSError("redis connection reset"))
94+
95+
result = auth.get_tok("any-token-id")
96+
97+
assert result == {}
98+
auth.tokens["localfs.rm_token"].assert_not_called()
99+
100+
101+
def test_get_tok_removes_token_on_deserialization_error(tmp_path):
102+
"""A corrupt token blob is permanently unusable; removing it is
103+
correct because every subsequent read would fail the same way."""
104+
auth = _make_auth(
105+
tmp_path,
106+
salt.exceptions.SaltDeserializationError("bad msgpack"),
107+
)
108+
109+
result = auth.get_tok("corrupt-token-id")
110+
111+
assert result == {}
112+
auth.tokens["localfs.rm_token"].assert_called_once_with(
113+
auth.opts, "corrupt-token-id"
114+
)
115+
116+
117+
def test_get_tok_removes_expired_token(tmp_path):
118+
"""Expired tokens are deserializable but past their ``expire``
119+
timestamp. They must be removed so the store does not accumulate
120+
dead entries."""
121+
expired_blob = {
122+
"token": "expired-token-id",
123+
"expire": time.time() - 60,
124+
"name": "foo",
125+
"eauth": "auto",
126+
}
127+
auth = _make_auth(tmp_path, lambda opts, tok: expired_blob)
128+
129+
result = auth.get_tok("expired-token-id")
130+
131+
assert result == {}
132+
auth.tokens["localfs.rm_token"].assert_called_once_with(
133+
auth.opts, "expired-token-id"
134+
)

0 commit comments

Comments
 (0)