Skip to content

Commit e72eae4

Browse files
roed314claude
andcommitted
Fix a race that could hand a worker an empty secret key
Creating the key with O_CREAT|O_EXCL makes the file appear before its contents are written, so a process arriving in that window took the 'someone else created it' branch and read an empty file -- an empty flask session key. CI caught this through the concurrency test (which passed locally by timing luck). Write the key to a temporary file, created owner-readable only by mkstemp, and link it into place once complete: under its final name the file is never empty or half-written, and the link failing with FileExistsError is what tells the losers of the race to use the existing key. The regression tests now repeat the threaded race and add a multi-process variant (the gunicorn-worker case), and assert that every reader got a complete 32-character key rather than only that they agree. Verified fail-without/pass-with by widening the create-to-write window: 5/5 runs broken before, 0/5 after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b10d4eb commit e72eae4

2 files changed

Lines changed: 57 additions & 24 deletions

File tree

lmfdb/config.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import secrets
3636
import string
3737
import socket
38+
import tempfile
3839
from contextlib import closing
3940
from logging import INFO
4041

@@ -133,20 +134,27 @@ def get_secret_key(config_file=None):
133134
"""
134135
if config_file is None:
135136
config_file = find_config_file()
136-
secret_key_file = os.path.join(os.path.dirname(os.path.abspath(config_file)), "secret_key")
137+
directory = os.path.dirname(os.path.abspath(config_file))
138+
secret_key_file = os.path.join(directory, "secret_key")
137139
if not os.path.exists(secret_key_file):
138-
os.makedirs(os.path.dirname(secret_key_file), exist_ok=True)
140+
os.makedirs(directory, exist_ok=True)
139141
key = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(32))
140-
# create the file atomically, readable only by the owner; if
141-
# another process creates it first, its key is used instead, so
142-
# concurrent workers always end up with the same key
142+
# Write the key to a temporary file (created readable only by the
143+
# owner) and link it into place once it is complete: under its
144+
# final name the file is therefore never empty or half-written, so
145+
# workers starting simultaneously either create the key or read a
146+
# complete one. The link fails if the name already exists, which
147+
# is how the losers of the race learn to use the existing key.
148+
fd, tmp = tempfile.mkstemp(dir=directory)
143149
try:
144-
fd = os.open(secret_key_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
145-
except FileExistsError:
146-
pass
147-
else:
148150
with os.fdopen(fd, "w") as F:
149151
F.write(key)
152+
try:
153+
os.link(tmp, secret_key_file)
154+
except FileExistsError:
155+
pass
156+
finally:
157+
os.unlink(tmp)
150158
with open(secret_key_file) as F:
151159
return F.read()
152160

lmfdb/test_config.py

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -77,22 +77,47 @@ def test_created_next_to_config_file(self, tmp_path):
7777
assert mode == 0o600
7878

7979
def test_concurrent_creation_yields_one_key(self, tmp_path):
80-
# concurrent workers must end up with the same key
80+
# Concurrent workers must all end up with the same complete key.
81+
# Repeated, because the window in which a reader could observe a
82+
# created-but-not-yet-written file is small.
83+
for i in range(20):
84+
directory = tmp_path / ("run%d" % i)
85+
directory.mkdir()
86+
cfg = str(directory / "config.ini")
87+
barrier = threading.Barrier(8)
88+
keys = []
89+
90+
def worker():
91+
barrier.wait()
92+
keys.append(config_mod.get_secret_key(cfg))
93+
94+
threads = [threading.Thread(target=worker) for _ in range(8)]
95+
for t in threads:
96+
t.start()
97+
for t in threads:
98+
t.join()
99+
assert all(len(k) == 32 for k in keys), "a worker read an incomplete key: %r" % keys
100+
assert len(set(keys)) == 1, keys
101+
102+
def test_concurrent_creation_across_processes(self, tmp_path):
103+
# the same race between separate processes, as when several
104+
# gunicorn workers start at once
81105
cfg = str(tmp_path / "config.ini")
82-
barrier = threading.Barrier(8)
83-
keys = []
84-
85-
def worker():
86-
barrier.wait()
87-
keys.append(config_mod.get_secret_key(cfg))
88-
89-
threads = [threading.Thread(target=worker) for _ in range(8)]
90-
for t in threads:
91-
t.start()
92-
for t in threads:
93-
t.join()
94-
assert len(set(keys)) == 1
95-
assert len(keys[0]) == 32
106+
code = (
107+
"import sys\n"
108+
"from lmfdb.config import get_secret_key\n"
109+
"sys.stdout.write(get_secret_key(%r))\n" % cfg
110+
)
111+
procs = [
112+
subprocess.Popen(
113+
[sys.executable, "-c", code], stdout=subprocess.PIPE, text=True
114+
)
115+
for _ in range(6)
116+
]
117+
keys = [p.communicate()[0] for p in procs]
118+
assert all(p.returncode == 0 for p in procs)
119+
assert all(len(k) == 32 for k in keys), "a process read an incomplete key: %r" % keys
120+
assert len(set(keys)) == 1, keys
96121

97122

98123
class TestConfiguration:

0 commit comments

Comments
 (0)