Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions aw-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ gethostname = "0.4"
uuid = { version = "1.3", features = ["serde", "v4"] }
clap = { version = "4.1", features = ["derive", "cargo"] }
log-panics = { version = "2", features = ["with-backtrace"]}
subtle = "2"
rust-embed = { version = "8.0.0", features = ["interpolate-folder-path", "debug-embed"] }

aw-datastore = { path = "../aw-datastore" }
Expand Down
12 changes: 12 additions & 0 deletions aw-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ pub struct AWConfig {
#[serde(skip, default = "default_testing")]
pub testing: bool, // This is not written to the config file (serde(skip))

/// Optional API key for bearer token authentication.
/// When set, all API endpoints (except /api/0/info) require:
/// Authorization: Bearer <api_key>
/// Leave unset (default) to disable authentication.
#[serde(default = "default_api_key")]
pub api_key: Option<String>,

#[serde(default = "default_cors")]
pub cors: Vec<String>,

Expand All @@ -48,6 +55,7 @@ impl Default for AWConfig {
address: default_address(),
port: default_port(),
testing: default_testing(),
api_key: default_api_key(),
cors: default_cors(),
cors_regex: default_cors(),
custom_static: default_custom_static(),
Expand Down Expand Up @@ -83,6 +91,10 @@ fn default_address() -> String {
"127.0.0.1".to_string()
}

fn default_api_key() -> Option<String> {
None
}

fn default_cors() -> Vec<String> {
Vec::<String>::new()
}
Expand Down
260 changes: 260 additions & 0 deletions aw-server/src/endpoints/apikey.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
//! API key authentication via Bearer token.
//!
//! When `api_key` is set in the config, all API endpoints except `/api/0/info`
//! require an `Authorization: Bearer <key>` header. Requests missing or
//! presenting an invalid key receive a 401 Unauthorized response.
//!
//! By default `api_key` is `None`, meaning authentication is disabled.
//! To enable, add to `config.toml`:
//!
//! ```toml
//! api_key = "your-secret-key-here"
//! ```
//!
//! Exempt paths (always public):
//! - `GET /api/0/info` — health/version endpoint used by clients and the webui
//!
//! CORS preflight requests (OPTIONS) are also passed through unconditionally so
//! the browser can obtain allowed headers before sending the actual request.

use subtle::ConstantTimeEq;

use rocket::fairing::Fairing;
use rocket::http::uri::Origin;
use rocket::http::{Method, Status};
use rocket::route::Outcome;
use rocket::{Data, Request, Rocket, Route};

use crate::config::AWConfig;
use crate::endpoints::HttpErrorJson;

static FAIRING_ROUTE_BASE: &str = "/apikey_fairing";

/// Paths that are always accessible without authentication.
const PUBLIC_PATHS: &[&str] = &["/api/0/info"];
Comment on lines +34 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Web UI routes blocked when API key is set

PUBLIC_PATHS only exempts /api/0/info, so all static web UI routes (/, /css/, /js/, /fonts/, etc.) are gated behind Bearer auth. A browser cannot send Authorization: Bearer headers for ordinary page navigation, so the ActivityWatch web UI becomes completely inaccessible as soon as a user sets api_key. This breaks the primary user interface for anyone enabling this feature on a desktop install.

Restricting the check to the /api/ subtree — keeping static asset routes public — would match the stated intent of "protecting the API":

Suggested change
/// Paths that are always accessible without authentication.
const PUBLIC_PATHS: &[&str] = &["/api/0/info"];
/// Paths that are always accessible without authentication.
const PUBLIC_PATHS: &[&str] = &["/api/0/info"];
/// Only enforce auth for paths under this prefix.
const PROTECTED_PREFIX: &str = "/api/";

Then in on_request, replace the PUBLIC_PATHS check with:

// Only enforce auth on API routes; static web-UI assets are always public.
if !request.uri().path().starts_with(PROTECTED_PREFIX) {
    return;
}

// Within the API, /api/0/info is always public.
if PUBLIC_PATHS.contains(&request.uri().path().as_str()) {
    return;
}


pub struct ApiKeyCheck {
api_key: Option<String>,
}

impl ApiKeyCheck {
pub fn new(config: &AWConfig) -> ApiKeyCheck {
let api_key = match &config.api_key {
Some(k) if k.is_empty() => {
warn!("api_key is set to an empty string — authentication is disabled. Set a non-empty key to enable auth.");
None
}
other => other.clone(),
};
ApiKeyCheck { api_key }
}
}

/// Route handler that returns 401 Unauthorized for failed auth checks.
#[derive(Clone)]
struct FairingErrorRoute {}

#[rocket::async_trait]
impl rocket::route::Handler for FairingErrorRoute {
async fn handle<'r>(
&self,
request: &'r Request<'_>,
_: rocket::Data<'r>,
) -> rocket::route::Outcome<'r> {
let err = HttpErrorJson::new(
Status::Unauthorized,
"Missing or invalid API key. Set 'Authorization: Bearer <key>' header.".to_string(),
);
Outcome::from(request, err)
}
Comment on lines +64 to +70
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing WWW-Authenticate response header

RFC 7235 §4.1 requires that a 401 response include at least one WWW-Authenticate challenge so HTTP clients can discover the supported scheme automatically. Without it, generic HTTP clients have no machine-readable signal to prompt for credentials.

(This requires a small refactor of FairingErrorRoute::handle to build a Response manually and append .raw_header("WWW-Authenticate", "Bearer realm=\"aw-server\"").)

}

fn fairing_route() -> Route {
Route::ranked(1, Method::Get, "/", FairingErrorRoute {})
}

fn redirect_unauthorized(request: &mut Request) {
let uri = FAIRING_ROUTE_BASE.to_string();
let origin = Origin::parse_owned(uri).unwrap();
request.set_method(Method::Get);
request.set_uri(origin);
}

#[rocket::async_trait]
impl Fairing for ApiKeyCheck {
fn info(&self) -> rocket::fairing::Info {
rocket::fairing::Info {
name: "ApiKeyCheck",
kind: rocket::fairing::Kind::Ignite | rocket::fairing::Kind::Request,
}
}

async fn on_ignite(&self, rocket: Rocket<rocket::Build>) -> rocket::fairing::Result {
match &self.api_key {
Some(_) => Ok(rocket.mount(FAIRING_ROUTE_BASE, vec![fairing_route()])),
None => {
debug!("API key authentication is disabled");
Ok(rocket)
}
}
}

async fn on_request(&self, request: &mut Request<'_>, _: &mut Data<'_>) {
let api_key = match &self.api_key {
None => return, // auth disabled
Some(k) => k,
};
Comment on lines +103 to +107
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Empty API key trivially bypassed

If a user sets api_key = "" in config, the guard accepts any request that sends Authorization: Bearer (Bearer followed by an empty token), since "Bearer ".strip_prefix("Bearer ") yields Some("") and "" == "" is true. Filtering this out at the point the key is read keeps the authentication logic clean.

Suggested change
async fn on_request(&self, request: &mut Request<'_>, _: &mut Data<'_>) {
let api_key = match &self.api_key {
None => return, // auth disabled
Some(k) => k,
};
let api_key = match &self.api_key {
None => return, // auth disabled
Some(k) if k.is_empty() => {
warn!("api_key is configured but empty — authentication is effectively disabled");
return;
}
Some(k) => k,
};


// Always allow OPTIONS (CORS preflight)
if request.method() == Method::Options {
return;
}

// Always allow public paths
if PUBLIC_PATHS.contains(&request.uri().path().as_str()) {
return;
}

// Validate Authorization: Bearer <key>
// Use constant-time comparison to prevent timing attacks.
let auth_header = request.headers().get_one("Authorization");
let valid = match auth_header {
Some(value) => {
if let Some(token) = value.strip_prefix("Bearer ") {
token.as_bytes().ct_eq(api_key.as_bytes()).into()
} else {
false
}
}
None => false,
};
Comment on lines +127 to +136
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Timing-safe comparison missing

The token == api_key comparison on line 116 short-circuits on the first mismatched byte, leaking timing information that a network-adjacent attacker can use as an oracle to guess the key character by character. This is especially relevant for the stated use case of non-localhost network exposure. Consider using the subtle crate's ConstantTimeEq or comparing HMAC digests of both values.

Suggested change
let valid = match auth_header {
Some(value) => {
if let Some(token) = value.strip_prefix("Bearer ") {
token == api_key
} else {
false
}
}
None => false,
};
let valid = match auth_header {
Some(value) => {
if let Some(token) = value.strip_prefix("Bearer ") {
// Use constant-time comparison to prevent timing oracle attacks
token.len() == api_key.len()
&& token
.bytes()
.zip(api_key.bytes())
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
== 0
} else {
false
}
}
None => false,
};


if !valid {
debug!("API key check failed for {}", request.uri());
redirect_unauthorized(request);
}
}
}

#[cfg(test)]
mod tests {
use std::sync::Mutex;

use rocket::http::{ContentType, Header, Status};
use rocket::Rocket;

use crate::config::AWConfig;
use crate::endpoints;

fn setup_testserver(api_key: Option<String>) -> Rocket<rocket::Build> {
let state = endpoints::ServerState {
datastore: Mutex::new(aw_datastore::Datastore::new_in_memory(false)),
asset_resolver: endpoints::AssetResolver::new(None),
device_id: "test_id".to_string(),
};
let mut aw_config = AWConfig::default();
aw_config.api_key = api_key;
endpoints::build_rocket(state, aw_config)
}

#[test]
fn test_no_api_key_configured() {
// When no api_key is set, all endpoints are accessible without auth.
let server = setup_testserver(None);
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");

let res = client
.get("/api/0/info")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(res.status(), Status::Ok);

let res = client
.get("/api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(res.status(), Status::Ok);
}

#[test]
fn test_api_key_required() {
// With api_key set, requests without a key should be rejected.
let server = setup_testserver(Some("secret123".to_string()));
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");

// /api/0/info is always public
let res = client
.get("/api/0/info")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(res.status(), Status::Ok);

// Other endpoints require auth
let res = client
.get("/api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(res.status(), Status::Unauthorized);
}

#[test]
fn test_api_key_valid() {
let server = setup_testserver(Some("secret123".to_string()));
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");

let res = client
.get("/api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Authorization", "Bearer secret123"))
.dispatch();
assert_eq!(res.status(), Status::Ok);
}

#[test]
fn test_api_key_invalid() {
let server = setup_testserver(Some("secret123".to_string()));
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");

let res = client
.get("/api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Authorization", "Bearer wrongkey"))
.dispatch();
assert_eq!(res.status(), Status::Unauthorized);
}

#[test]
fn test_api_key_wrong_scheme() {
// Must be Bearer, not Basic or bare key
let server = setup_testserver(Some("secret123".to_string()));
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");

let res = client
.get("/api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Authorization", "Basic secret123"))
.dispatch();
assert_eq!(res.status(), Status::Unauthorized);
}

#[test]
fn test_empty_api_key_disables_auth() {
// An empty string key should be treated as disabled (no auth required).
let server = setup_testserver(Some("".to_string()));
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");

let res = client
.get("/api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(res.status(), Status::Ok);
}
}
3 changes: 3 additions & 0 deletions aw-server/src/endpoints/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub struct ServerState {

#[macro_use]
mod util;
mod apikey;
mod bucket;
mod cors;
mod export;
Expand Down Expand Up @@ -134,11 +135,13 @@ pub fn build_rocket(server_state: ServerState, config: AWConfig) -> rocket::Rock
);
let cors = cors::cors(&config);
let hostcheck = hostcheck::HostCheck::new(&config);
let apikey = apikey::ApiKeyCheck::new(&config);
let custom_static = config.custom_static.clone();

let mut rocket = rocket::custom(config.to_rocket_config())
.attach(cors.clone())
.attach(hostcheck)
.attach(apikey)
.manage(cors)
.manage(server_state)
.manage(config)
Expand Down
Loading