Skip to content

Commit ca6e0e1

Browse files
committed
Harden Web authentication startup
1 parent f6b2ec0 commit ca6e0e1

3 files changed

Lines changed: 119 additions & 18 deletions

File tree

herdr/plugin.sh

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -240,26 +240,23 @@ web)
240240
log="$dir/web.log"
241241
url="http://$web_listen"
242242
# Reuse a server that is already up — the background index service may well be serving it.
243-
if ! curl -fsS -m 1 "$url/api/stats" >/dev/null 2>&1; then
243+
if ! curl -fsS -m 1 "$url/healthz" >/dev/null 2>&1; then
244244
nohup "$MEMEX" web --listen "$web_listen" ${MEMEX_ROOT:+--root "$MEMEX_ROOT"} >>"$log" 2>&1 </dev/null &
245245
# The assets are embedded in the binary, so it comes up fast; give it ~3s anyway.
246246
up=""
247247
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
248248
sleep 0.2
249-
if curl -fsS -m 1 "$url/api/stats" >/dev/null 2>&1; then
249+
if curl -fsS -m 1 "$url/healthz" >/dev/null 2>&1; then
250250
up=1
251251
break
252252
fi
253253
done
254254
[ -n "$up" ] || refuse "memex web did not come up on $web_listen, see $log"
255255
fi
256-
case "$(uname -s)" in
257-
Darwin) opener="open" ;;
258-
*) opener="xdg-open" ;;
259-
esac
260-
command -v "$opener" >/dev/null 2>&1 || refuse "$opener not found; memex web is running at $url"
261-
"$opener" "$url" >/dev/null 2>&1 || refuse "failed to open $url with $opener"
262-
printf 'opened %s\n' "$url"
256+
open_args=(index-service open --listen "$web_listen")
257+
[ -n "${MEMEX_ROOT:-}" ] && open_args+=(--root "$MEMEX_ROOT")
258+
"$MEMEX" "${open_args[@]}" >/dev/null 2>&1 || refuse "failed to open authenticated memex web UI at $url"
259+
printf 'opened authenticated %s\n' "$url"
263260
;;
264261

265262
startup)

src/web.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ fn handle_request(
145145
"application/javascript; charset=utf-8",
146146
false,
147147
),
148+
"/healthz" => respond_text(request, StatusCode(200), "ok", "text/plain"),
148149
"/api/stats" => match stats_payload(paths) {
149150
Ok(payload) => respond_json(request, StatusCode(200), &payload),
150151
Err(err) => respond_json_error(request, StatusCode(503), &err.to_string()),
@@ -1081,6 +1082,15 @@ mod tests {
10811082
);
10821083
assert!(bootstrap_page.starts_with("HTTP/1.1 200"));
10831084

1085+
let health = http_round_trip(
1086+
&paths,
1087+
&auth,
1088+
"GET /healthz HTTP/1.1\r\nHost: localhost:6363\r\nConnection: close\r\n\r\n"
1089+
.to_string(),
1090+
);
1091+
assert!(health.starts_with("HTTP/1.1 200"));
1092+
assert!(health.ends_with("ok"));
1093+
10841094
let unauthorized = http_round_trip(
10851095
&paths,
10861096
&auth,

src/web_auth.rs

Lines changed: 103 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -136,19 +136,80 @@ fn load_or_create_secret(paths: &Paths) -> Result<[u8; ACCESS_TOKEN_BYTES]> {
136136
std::fs::create_dir_all(&paths.root)
137137
.with_context(|| format!("failed to create {}", paths.root.display()))?;
138138
let path = token_path(paths);
139-
match open_secret_for_create(&path) {
140-
Ok(mut file) => {
141-
let mut secret = [0_u8; ACCESS_TOKEN_BYTES];
142-
fill_random(&mut secret)?;
143-
let encoded = URL_SAFE_NO_PAD.encode(secret);
139+
match publish_new_secret(&path)? {
140+
Some(secret) => Ok(secret),
141+
None => read_secret(&path),
142+
}
143+
}
144+
145+
fn publish_new_secret(path: &Path) -> Result<Option<[u8; ACCESS_TOKEN_BYTES]>> {
146+
let parent = path
147+
.parent()
148+
.ok_or_else(|| anyhow!("web auth token path has no parent: {}", path.display()))?;
149+
let mut secret = [0_u8; ACCESS_TOKEN_BYTES];
150+
fill_random(&mut secret)?;
151+
let encoded = URL_SAFE_NO_PAD.encode(secret);
152+
153+
for _ in 0..16 {
154+
let mut suffix = [0_u8; 16];
155+
fill_random(&mut suffix)?;
156+
let temp_path = parent.join(format!(
157+
".{TOKEN_FILE}.{}.tmp",
158+
URL_SAFE_NO_PAD.encode(suffix)
159+
));
160+
let mut file = match open_secret_for_create(&temp_path) {
161+
Ok(file) => file,
162+
Err(err) if err.kind() == ErrorKind::AlreadyExists => continue,
163+
Err(err) => {
164+
return Err(err)
165+
.with_context(|| format!("failed to create {}", temp_path.display()));
166+
}
167+
};
168+
169+
let write_result = (|| -> std::io::Result<()> {
144170
file.write_all(encoded.as_bytes())?;
145171
file.write_all(b"\n")?;
146-
file.sync_all()?;
147-
Ok(secret)
172+
file.sync_all()
173+
})();
174+
if let Err(err) = write_result {
175+
drop(file);
176+
let _ = std::fs::remove_file(&temp_path);
177+
return Err(err).with_context(|| format!("failed to write {}", temp_path.display()));
178+
}
179+
drop(file);
180+
181+
match std::fs::hard_link(&temp_path, path) {
182+
Ok(()) => {
183+
std::fs::remove_file(&temp_path).with_context(|| {
184+
format!("failed to remove temporary token {}", temp_path.display())
185+
})?;
186+
sync_directory(parent)?;
187+
return Ok(Some(secret));
188+
}
189+
Err(err) if err.kind() == ErrorKind::AlreadyExists => {
190+
let _ = std::fs::remove_file(&temp_path);
191+
return Ok(None);
192+
}
193+
Err(err) => {
194+
let _ = std::fs::remove_file(&temp_path);
195+
return Err(err).with_context(|| format!("failed to publish {}", path.display()));
196+
}
148197
}
149-
Err(err) if err.kind() == ErrorKind::AlreadyExists => read_secret(&path),
150-
Err(err) => Err(err).with_context(|| format!("failed to create {}", path.display())),
151198
}
199+
200+
bail!("failed to allocate a temporary web auth token file")
201+
}
202+
203+
#[cfg(unix)]
204+
fn sync_directory(path: &Path) -> Result<()> {
205+
File::open(path)
206+
.and_then(|directory| directory.sync_all())
207+
.with_context(|| format!("failed to sync {}", path.display()))
208+
}
209+
210+
#[cfg(not(unix))]
211+
fn sync_directory(_path: &Path) -> Result<()> {
212+
Ok(())
152213
}
153214

154215
fn read_secret(path: &Path) -> Result<[u8; ACCESS_TOKEN_BYTES]> {
@@ -254,6 +315,39 @@ mod tests {
254315
);
255316
}
256317

318+
#[test]
319+
fn concurrent_first_use_publishes_one_complete_access_token() {
320+
let temp = TempDir::new().unwrap();
321+
let root = temp.path().to_path_buf();
322+
let barrier = std::sync::Arc::new(std::sync::Barrier::new(16));
323+
let threads: Vec<_> = (0..16)
324+
.map(|_| {
325+
let root = root.clone();
326+
let barrier = std::sync::Arc::clone(&barrier);
327+
std::thread::spawn(move || {
328+
let paths = Paths::new(Some(root)).unwrap();
329+
barrier.wait();
330+
WebAuth::load_or_create(&paths).unwrap()
331+
})
332+
})
333+
.collect();
334+
let auths: Vec<_> = threads
335+
.into_iter()
336+
.map(|thread| thread.join().unwrap())
337+
.collect();
338+
let paths = Paths::new(Some(root)).unwrap();
339+
let token = std::fs::read_to_string(token_path(&paths)).unwrap();
340+
341+
assert!(auths.iter().all(|auth| auth.authorize_bearer(token.trim())));
342+
assert_eq!(
343+
std::fs::read_dir(&paths.root)
344+
.unwrap()
345+
.filter_map(Result::ok)
346+
.count(),
347+
1
348+
);
349+
}
350+
257351
#[test]
258352
fn bootstrap_tokens_are_short_lived_and_single_use() {
259353
let temp = TempDir::new().unwrap();

0 commit comments

Comments
 (0)