Skip to content

Commit 6f37929

Browse files
committed
gw: support HTTP Basic Auth on admin server
Plain browsers can't set custom headers when typing a URL, so the existing `X-Admin-Token` / `Bearer` / `?token=` paths leave dashboard operators with only the query-param option — which leaks the token via URL bar, history, and Referer. Add `Authorization: Basic <base64(user:pass)>` as a fourth accepted transport. The token may sit in either the username or password field (operators often paste into either side of the native browser prompt), and the 401 sentinel now emits `WWW-Authenticate: Basic realm="..."` so the browser actually shows the prompt instead of a plain error page. RPC clients are unaffected: the new header just goes alongside the existing transports and they ignore the WWW-Authenticate header. Adds 5 tests: password field, username field, wrong password, malformed base64, and the WWW-Authenticate challenge header on 401.
1 parent 6997499 commit 6f37929

1 file changed

Lines changed: 133 additions & 22 deletions

File tree

gateway/src/admin_auth.rs

Lines changed: 133 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,31 @@
88
//! present the configured shared secret. The token is accepted via, in order:
99
//! 1. `X-Admin-Token` header (any method)
1010
//! 2. `Authorization: Bearer <token>` header (any method)
11-
//! 3. `?token=<token>` query parameter (GET only, for dashboard links)
11+
//! 3. `Authorization: Basic <base64(user:token)>` (any method; token may be
12+
//! in either the user or password field — needed so plain browsers can
13+
//! authenticate to the dashboard via the native HTTP-auth prompt)
14+
//! 4. `?token=<token>` query parameter (GET only, for dashboard links)
1215
//!
13-
//! For (3), the `token` query parameter is stripped from the request URI after
16+
//! For (4), the `token` query parameter is stripped from the request URI after
1417
//! successful validation so it doesn't propagate to access logs, downstream
1518
//! handlers, or the Referer header.
1619
//!
17-
//! Rejected requests are forwarded to a sentinel route that returns HTTP 401,
18-
//! so all admin routes (prpc-generated and dashboard) are protected by a single
19-
//! attachment without modifying the route declarations.
20+
//! Rejected requests are forwarded to a sentinel route that returns HTTP 401
21+
//! with `WWW-Authenticate: Basic realm="dstack-gateway admin"` so browsers
22+
//! show the native login prompt. All admin routes (prpc-generated and
23+
//! dashboard) are protected by this single fairing attachment without
24+
//! modifying the route declarations.
2025
//!
2126
//! The token is only ever held in memory as its SHA-256 hash; the configured
2227
//! plaintext is dropped right after the fairing is constructed.
2328
2429
use anyhow::{bail, Result};
30+
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
2531
use rocket::{
2632
fairing::{Fairing, Info, Kind},
27-
http::{uri::Origin, Method, Status},
28-
Data, Request, Route,
33+
http::{uri::Origin, Header, Method, Status},
34+
response::Responder,
35+
Data, Request, Response, Route,
2936
};
3037
use sha2::{Digest, Sha256};
3138
use subtle::ConstantTimeEq;
@@ -37,6 +44,7 @@ const HEADER_NAME: &str = "X-Admin-Token";
3744
const QUERY_PARAM: &str = "token";
3845
const ENV_ADMIN_TOKEN: &str = "DSTACK_GATEWAY_ADMIN_TOKEN";
3946
const ENV_ADMIN_TOKEN_COMPAT: &str = "ADMIN_API_TOKEN";
47+
const BASIC_REALM: &str = "dstack-gateway admin";
4048

4149
pub struct AdminAuthFairing {
4250
/// SHA-256 of the configured token. `None` = auth disabled (insecure mode).
@@ -83,7 +91,12 @@ impl AdminAuthFairing {
8391
}
8492
if let Some(auth) = req.headers().get_one("Authorization") {
8593
if let Some(t) = auth.strip_prefix("Bearer ") {
86-
return Some(t.to_string());
94+
return Some(t.trim().to_string());
95+
}
96+
if let Some(b64) = auth.strip_prefix("Basic ") {
97+
if let Some(t) = basic_auth_token(b64.trim()) {
98+
return Some(t);
99+
}
87100
}
88101
}
89102
// Query token is intended for browser links to the dashboard, so only
@@ -103,6 +116,37 @@ fn sha256(bytes: &[u8]) -> [u8; 32] {
103116
Sha256::digest(bytes).into()
104117
}
105118

119+
/// Decode a `Basic` credential and return whichever of user/password is
120+
/// non-empty (we accept either so the browser prompt's two fields are
121+
/// interchangeable for the operator).
122+
fn basic_auth_token(b64: &str) -> Option<String> {
123+
let decoded = BASE64.decode(b64).ok()?;
124+
let text = std::str::from_utf8(&decoded).ok()?;
125+
let (user, pass) = text.split_once(':').unwrap_or((text, ""));
126+
if !pass.is_empty() {
127+
return Some(pass.to_string());
128+
}
129+
if !user.is_empty() {
130+
return Some(user.to_string());
131+
}
132+
None
133+
}
134+
135+
/// 401 response that triggers the browser's native HTTP-auth prompt.
136+
struct Unauthorized;
137+
138+
impl<'r> Responder<'r, 'static> for Unauthorized {
139+
fn respond_to(self, _req: &'r Request<'_>) -> rocket::response::Result<'static> {
140+
Response::build()
141+
.status(Status::Unauthorized)
142+
.header(Header::new(
143+
"WWW-Authenticate",
144+
format!("Basic realm=\"{BASIC_REALM}\""),
145+
))
146+
.ok()
147+
}
148+
}
149+
106150
/// Rebuild the request URI without the `token` query parameter, if present.
107151
/// Returns `None` when there is nothing to strip.
108152
fn strip_token_query(uri: &Origin<'_>) -> Option<Origin<'static>> {
@@ -166,38 +210,38 @@ impl Fairing for AdminAuthFairing {
166210
// enumerate them because Rocket doesn't support a method-agnostic route.
167211

168212
#[rocket::get("/__admin_unauthorized")]
169-
fn unauth_get() -> Status {
170-
Status::Unauthorized
213+
fn unauth_get() -> Unauthorized {
214+
Unauthorized
171215
}
172216

173217
#[rocket::post("/__admin_unauthorized", data = "<_data>")]
174-
fn unauth_post(_data: Data<'_>) -> Status {
175-
Status::Unauthorized
218+
fn unauth_post(_data: Data<'_>) -> Unauthorized {
219+
Unauthorized
176220
}
177221

178222
#[rocket::put("/__admin_unauthorized", data = "<_data>")]
179-
fn unauth_put(_data: Data<'_>) -> Status {
180-
Status::Unauthorized
223+
fn unauth_put(_data: Data<'_>) -> Unauthorized {
224+
Unauthorized
181225
}
182226

183227
#[rocket::patch("/__admin_unauthorized", data = "<_data>")]
184-
fn unauth_patch(_data: Data<'_>) -> Status {
185-
Status::Unauthorized
228+
fn unauth_patch(_data: Data<'_>) -> Unauthorized {
229+
Unauthorized
186230
}
187231

188232
#[rocket::delete("/__admin_unauthorized")]
189-
fn unauth_delete() -> Status {
190-
Status::Unauthorized
233+
fn unauth_delete() -> Unauthorized {
234+
Unauthorized
191235
}
192236

193237
#[rocket::options("/__admin_unauthorized")]
194-
fn unauth_options() -> Status {
195-
Status::Unauthorized
238+
fn unauth_options() -> Unauthorized {
239+
Unauthorized
196240
}
197241

198242
#[rocket::head("/__admin_unauthorized")]
199-
fn unauth_head() -> Status {
200-
Status::Unauthorized
243+
fn unauth_head() -> Unauthorized {
244+
Unauthorized
201245
}
202246

203247
pub fn routes() -> Vec<Route> {
@@ -444,4 +488,71 @@ mod tests {
444488
);
445489
}
446490
}
491+
492+
fn basic_header(user: &str, pass: &str) -> Header<'static> {
493+
let creds = format!("{user}:{pass}");
494+
Header::new("Authorization", format!("Basic {}", BASE64.encode(creds)))
495+
}
496+
497+
#[rocket::async_test]
498+
async fn basic_auth_password_field_accepted() {
499+
let client = make_client("s3cret").await;
500+
let resp = client
501+
.get("/protected")
502+
.header(basic_header("admin", "s3cret"))
503+
.dispatch()
504+
.await;
505+
assert_eq!(resp.status(), Status::Ok);
506+
}
507+
508+
#[rocket::async_test]
509+
async fn basic_auth_user_field_accepted_when_password_empty() {
510+
let client = make_client("s3cret").await;
511+
// Some browser users paste the token into the username field by mistake.
512+
let resp = client
513+
.get("/protected")
514+
.header(basic_header("s3cret", ""))
515+
.dispatch()
516+
.await;
517+
assert_eq!(resp.status(), Status::Ok);
518+
}
519+
520+
#[rocket::async_test]
521+
async fn basic_auth_wrong_password_rejected() {
522+
let client = make_client("s3cret").await;
523+
let resp = client
524+
.get("/protected")
525+
.header(basic_header("admin", "wrong"))
526+
.dispatch()
527+
.await;
528+
assert_eq!(resp.status(), Status::Unauthorized);
529+
}
530+
531+
#[rocket::async_test]
532+
async fn basic_auth_malformed_rejected() {
533+
let client = make_client("s3cret").await;
534+
// Not valid base64 at all.
535+
let resp = client
536+
.get("/protected")
537+
.header(Header::new("Authorization", "Basic !!not-base64!!"))
538+
.dispatch()
539+
.await;
540+
assert_eq!(resp.status(), Status::Unauthorized);
541+
}
542+
543+
#[rocket::async_test]
544+
async fn unauthorized_response_includes_www_authenticate() {
545+
let client = make_client("s3cret").await;
546+
let resp = client.get("/protected").dispatch().await;
547+
assert_eq!(resp.status(), Status::Unauthorized);
548+
let www = resp
549+
.headers()
550+
.get_one("WWW-Authenticate")
551+
.expect("missing WWW-Authenticate header");
552+
assert!(
553+
www.starts_with("Basic realm="),
554+
"expected Basic challenge, got {www:?}"
555+
);
556+
assert!(www.contains("dstack-gateway admin"));
557+
}
447558
}

0 commit comments

Comments
 (0)