fix(homecore-api): close WS auth bypass + reply-theater, harden dev bin (ADR-161 A1/A2/A8)

A1 (CRITICAL): the /api/websocket handshake accepted any non-empty token,
ignoring the LongLivedTokenStore whitelist the REST path enforces — a full
WS auth bypass. Now validates via state.tokens().is_valid() before auth_ok;
wrong tokens get auth_invalid + close.

A2 (HIGH): WS command replies were pushed into an mpsc whose only consumer
logged and discarded them — no result/pong/event reached the client. Split
the socket with futures StreamExt::split; a dedicated writer task drains the
response channel onto the wire.

A8 (HIGH): the homecore-api dev bin bound 0.0.0.0 with unconditional
allow-any auth and no env path. Wired the HOMECORE_TOKENS env path (dev
fallback warn-logged when unset) and defaulted the bind to 127.0.0.1
(HOMECORE_BIND to opt into LAN).

Tests (fail on old source):
- ws_handshake::wrong_token_is_rejected (old → auth_ok)
- ws_handshake::result_reply_is_received / ping_pong_reply_is_received (old → timeout)
- server_bin_auth::provisioned_bin_rejects_wrong_bearer / from_env_path_enforces_whitelist

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-06-12 00:55:16 -04:00
parent 8487192d0f
commit 9d52d49c0b
6 changed files with 387 additions and 62 deletions
+48 -8
View File
@@ -1,15 +1,31 @@
//! `homecore-api-server` binary. Boots a HomeCore runtime and serves
//! the HA-compat REST + WS API on `:8123`.
//! the HA-compat REST + WS API.
//!
//! P1: bare-minimum bring-up. No persistence, no plugins, no auth
//! beyond "any non-empty bearer". Useful for `curl` smoke tests of
//! the wire format from the existing HA companion app:
//! ## Auth (ADR-161, HC-WS-08)
//!
//! Token provisioning matches `homecore-server`: if `HOMECORE_TOKENS`
//! is set (comma-separated bearer tokens) the API enforces that
//! whitelist on both the REST and WS paths. If it is **unset**, the
//! binary falls back to an explicitly-logged DEV mode (any non-empty
//! bearer accepted) — before this fix the bin unconditionally used
//! `allow_any_non_empty()` with no env path, so a provisioned operator
//! had no way to lock it down.
//!
//! ## Bind address
//!
//! Defaults to `127.0.0.1` (loopback only) so a bare `cargo run` of
//! this dev binary is not network-exposed. Override with
//! `HOMECORE_BIND=0.0.0.0:8123` for a LAN deployment (and provision
//! `HOMECORE_TOKENS` when you do).
//!
//! cargo run -p homecore-api --bin homecore-api-server
//! curl -H "Authorization: Bearer test" http://127.0.0.1:8123/api/
//! HOMECORE_TOKENS=secret curl -H "Authorization: Bearer secret" \
//! http://127.0.0.1:8123/api/
use std::net::SocketAddr;
use homecore::HomeCore;
use homecore_api::{router, SharedState, DEFAULT_PORT};
use homecore_api::{router, LongLivedTokenStore, SharedState, DEFAULT_PORT};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -21,10 +37,34 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.init();
let homecore = HomeCore::new();
let state = SharedState::new(homecore);
// Token provisioning (HC-WS-08). Prefer the HOMECORE_TOKENS env
// whitelist; fall back to DEV mode (warn-logged) only when unset.
let tokens = if std::env::var("HOMECORE_TOKENS")
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
{
let s = LongLivedTokenStore::from_env();
let n = s.len().await;
tracing::info!("LongLivedTokenStore provisioned with {n} bearer token(s) from HOMECORE_TOKENS");
s
} else {
tracing::warn!(
"HOMECORE_TOKENS not set — token store in DEV mode (any non-empty bearer \
accepted). Set HOMECORE_TOKENS before exposing this binary to the network."
);
LongLivedTokenStore::allow_any_non_empty()
};
let state = SharedState::with_tokens(homecore, "Home", env!("CARGO_PKG_VERSION"), tokens);
let app = router(state);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], DEFAULT_PORT));
// Default to loopback so `cargo run` is not network-exposed; allow
// an explicit HOMECORE_BIND override for LAN deployments.
let addr: SocketAddr = match std::env::var("HOMECORE_BIND") {
Ok(v) if !v.trim().is_empty() => v.parse()?,
_ => SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT)),
};
tracing::info!("HOMECORE-API listening on http://{addr} (HA-compat /api + /api/websocket)");
let listener = tokio::net::TcpListener::bind(addr).await?;
+79 -45
View File
@@ -9,6 +9,16 @@
//!
//! `ha_version` is the homecore version string — see ADR-130 Q1 for the
//! companion-app feature-detect concern.
//!
//! ## Security (ADR-161)
//!
//! The `auth` token is validated against [`crate::tokens::LongLivedTokenStore`]
//! via `state.tokens().is_valid()` — the *same* store the REST path uses
//! (`auth::BearerAuth`). A wrong token receives `auth_invalid` and the socket
//! is closed. (HC-WS-01 closed the prior bypass where any non-empty token was
//! accepted.) Command replies are transmitted by a dedicated writer task that
//! drains the response channel onto the socket (HC-WS-02 closed the prior
//! reply-theater where responses were logged and discarded).
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
@@ -18,7 +28,7 @@ use axum::extract::State;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use tracing::{debug, warn};
use tracing::warn;
use homecore::{Context, ServiceCall, ServiceName, SystemEvent};
@@ -58,11 +68,18 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) {
_ => return,
};
// P1: accept any non-empty token. P2: validate against store.
if token.trim().is_empty() {
// Validate the bearer token against the same store the REST path
// uses (`state.tokens().is_valid()` — see `rest.rs` /
// `auth::BearerAuth`). Before the HC-WS-01 fix this checked only
// `token.trim().is_empty()` and accepted ANY non-empty token even
// with a provisioned `HOMECORE_TOKENS` whitelist — a full WS auth
// bypass. `is_valid()` rejects the empty token internally and, in
// DEV (`allow_any`) mode, still accepts any non-empty bearer (with
// a warn) so smoke tests keep working.
if !state.tokens().is_valid(&token).await {
let _ = socket
.send(Message::Text(
serde_json::json!({"type":"auth_invalid","message":"empty token"}).to_string(),
serde_json::json!({"type":"auth_invalid","message":"invalid token"}).to_string(),
))
.await;
return;
@@ -140,54 +157,71 @@ impl Connection {
}
}
async fn run(self, mut socket: WebSocket) {
async fn run(self, socket: WebSocket) {
use futures_util::{SinkExt, StreamExt};
let conn = Arc::new(self);
// Split the socket so a dedicated writer task can drain `rx` onto
// the wire while the reader task processes commands concurrently.
// Before the HC-WS-02 fix the socket was moved into a recv-only
// task and the only `rx` consumer just `debug!`-logged and
// DISCARDED every message — so no `result`/`pong`/`event` ever
// reached the client. Now `rx` feeds `socket.send`.
let (mut sink, mut stream) = socket.split();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let sender_tx = tx.clone();
let recv_task = {
let conn = Arc::clone(&conn);
tokio::spawn(async move {
while let Some(frame) = socket.recv().await {
match frame {
Ok(Message::Text(raw)) => {
let cmd: WsCommand = match serde_json::from_str(&raw) {
Ok(c) => c,
Err(e) => {
warn!("bad ws command: {e}");
continue;
}
};
conn.handle_cmd(cmd, &sender_tx).await;
}
Ok(Message::Ping(p)) => {
let _ = sender_tx.send(format!("__pong:{}", p.len()));
}
Ok(Message::Close(_)) | Err(_) => break,
_ => {}
}
// Writer task: drain replies onto the socket. A `__pong:<n>`
// sentinel maps to a binary Pong control frame; everything else
// is a JSON text frame.
let writer_task = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
let send_result = if let Some(n) = msg.strip_prefix("__pong:") {
let len: usize = n.parse().unwrap_or(0);
sink.send(Message::Pong(vec![0u8; len])).await
} else {
sink.send(Message::Text(msg)).await
};
if send_result.is_err() {
break;
}
// Cancel all subscriptions on disconnect.
for entry in conn.subs.iter() {
entry.value().abort.abort();
}
});
}
});
tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if msg.starts_with("__pong:") {
// pong handled inline; skip
continue;
// Reader task: parse and dispatch commands; responses are pushed
// into `tx` and transmitted by the writer task above.
let reader_tx = tx.clone();
{
let conn = Arc::clone(&conn);
while let Some(frame) = stream.next().await {
match frame {
Ok(Message::Text(raw)) => {
let cmd: WsCommand = match serde_json::from_str(&raw) {
Ok(c) => c,
Err(e) => {
warn!("bad ws command: {e}");
continue;
}
};
conn.handle_cmd(cmd, &reader_tx).await;
}
// Use the socket from the recv task via a one-shot mpsc
// (in this minimal P1, the recv task owns the socket
// and we ack inline below — this branch is for the
// subscription fan-out emit path)
debug!("ws emit: {msg}");
Ok(Message::Ping(p)) => {
let _ = reader_tx.send(format!("__pong:{}", p.len()));
}
Ok(Message::Close(_)) | Err(_) => break,
_ => {}
}
})
};
let _ = recv_task.await;
}
// Cancel all subscriptions on disconnect.
for entry in conn.subs.iter() {
entry.value().abort.abort();
}
}
// Reader loop ended → drop the senders so the writer task's `rx`
// closes and the task exits cleanly.
drop(tx);
drop(reader_tx);
let _ = writer_task.await;
}
async fn handle_cmd(&self, cmd: WsCommand, tx: &tokio::sync::mpsc::UnboundedSender<String>) {