diff --git a/crates/buzz-avnu-proxy/src/main.rs b/crates/buzz-avnu-proxy/src/main.rs index 452b6905ebe..ffbf0adb8c9 100644 --- a/crates/buzz-avnu-proxy/src/main.rs +++ b/crates/buzz-avnu-proxy/src/main.rs @@ -6,13 +6,18 @@ //! //! # Security //! -//! This binary must **not** be an unauthenticated open relay: -//! - Default bind is loopback only (`127.0.0.1:8788`). +//! Default (no `PROXY_PUBLIC`): +//! - Bind is loopback only (`127.0.0.1:8788`). //! - Non-loopback binds require `PROXY_AUTH_TOKEN` (Bearer) on every `/` and //! `/rpc` request. //! - No `CORS Any` — the product path is Tauri `reqwest`, not a browser. -//! - Production sponsorship is the AWS paymaster (egress-only, no ingress); -//! do not expose this proxy on `0.0.0.0` without auth. +//! +//! Product open mode (`PROXY_PUBLIC=1`): +//! - Non-loopback bind is allowed without `PROXY_AUTH_TOKEN`. +//! - `/` and `/rpc` do not require Bearer. +//! - Abuse control is AVNU credits / upstream — not a shared secret. +//! - Desktop product URL (`https://paymaster.bitcoinmarkets.app`) needs no +//! client args or process-env token; Bearer is only for custom proxy URLs. //! //! # Required environment //! @@ -28,9 +33,14 @@ //! Default: https://starknet.paymaster.avnu.fi //! Test: https://sepolia.paymaster.avnu.fi //! BIND_ADDR Listen address. Default: 127.0.0.1:8788 (loopback). -//! Non-loopback requires PROXY_AUTH_TOKEN. -//! PROXY_AUTH_TOKEN Shared secret; required when binding off-loopback. -//! Clients send `Authorization: Bearer `. +//! Non-loopback requires PROXY_AUTH_TOKEN unless +//! PROXY_PUBLIC=1. +//! PROXY_AUTH_TOKEN Shared secret; required when binding off-loopback +//! without PROXY_PUBLIC. Clients send +//! `Authorization: Bearer `. +//! PROXY_PUBLIC Set to `1` for the hosted product paymaster: allow +//! non-loopback without Bearer. Default unset keeps +//! local/dev fail-closed. //! //! INDEXER_URL Required on listing clients (or product public host //! https://markets.bitcoinmarkets.app). NO localhost @@ -72,7 +82,8 @@ enum BootError { MissingApiKey, #[error( "BIND_ADDR {0:?} is not loopback; set PROXY_AUTH_TOKEN so this is not an \ - unauthenticated open relay (production sponsorship is the AWS paymaster)" + unauthenticated open relay, or set PROXY_PUBLIC=1 for the product \ + paymaster (abuse control = AVNU credits)" )] NonLoopbackRequiresAuth(String), #[error("invalid BIND_ADDR {0:?}: {1}")] @@ -88,6 +99,38 @@ fn is_loopback(addr: &SocketAddr) -> bool { } } +/// Product-open bind: non-loopback without Bearer. Env truthy values: `1`/`true`/`yes`. +fn proxy_public_enabled(raw: Option<&str>) -> bool { + matches!( + raw.map(str::trim).map(str::to_ascii_lowercase).as_deref(), + Some("1") | Some("true") | Some("yes") + ) +} + +/// Resolve whether Bearer auth is required for this bind. +/// +/// Returns `Ok(Some(token))` when auth is on, `Ok(None)` for loopback or +/// `PROXY_PUBLIC`, and `Err` for non-loopback without token or public flag. +fn resolve_auth_token( + addr: &SocketAddr, + bind_raw: &str, + public: bool, + token_env: Option<&str>, +) -> Result, BootError> { + let token = token_env + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + + if public { + return Ok(None); + } + if !is_loopback(addr) && token.is_none() { + return Err(BootError::NonLoopbackRequiresAuth(bind_raw.to_string())); + } + Ok(token) +} + #[tokio::main] async fn main() -> Result<(), BootError> { tracing_subscriber::fmt() @@ -129,13 +172,24 @@ async fn main() -> Result<(), BootError> { BootError::BadBind(bind_raw.clone(), e.to_string()) })?; - let auth_token = std::env::var("PROXY_AUTH_TOKEN") - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); + let public = proxy_public_enabled(std::env::var("PROXY_PUBLIC").ok().as_deref()); + let auth_token = resolve_auth_token( + &addr, + &bind_raw, + public, + std::env::var("PROXY_AUTH_TOKEN").ok().as_deref(), + )?; - if !is_loopback(&addr) && auth_token.is_none() { - return Err(BootError::NonLoopbackRequiresAuth(bind_raw)); + if public { + warn!( + %addr, + "PROXY_PUBLIC=1: open product paymaster — / and /rpc require no Bearer; \ + abuse control is AVNU credits / upstream" + ); + } else if auth_token.is_some() { + info!(%addr, "buzz-avnu-proxy requiring Bearer on / and /rpc"); + } else { + info!(%addr, "buzz-avnu-proxy loopback-only (no Bearer)"); } let state = Arc::new(AppState { @@ -182,7 +236,7 @@ fn unauthorized() -> Response { fn authorize(state: &AppState, headers: &HeaderMap) -> bool { let Some(expected) = state.auth_token.as_deref() else { - // Loopback bind without token — local-only. + // Loopback bind without token, or PROXY_PUBLIC product mode. return true; }; let Some(value) = headers @@ -275,3 +329,59 @@ async fn proxy_rpc( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn proxy_public_env_truthy() { + assert!(proxy_public_enabled(Some("1"))); + assert!(proxy_public_enabled(Some("true"))); + assert!(proxy_public_enabled(Some("YES"))); + assert!(!proxy_public_enabled(None)); + assert!(!proxy_public_enabled(Some(""))); + assert!(!proxy_public_enabled(Some("0"))); + assert!(!proxy_public_enabled(Some("false"))); + } + + #[test] + fn non_loopback_without_token_fails_closed() { + let addr: SocketAddr = "0.0.0.0:8788".parse().unwrap(); + let err = resolve_auth_token(&addr, "0.0.0.0:8788", false, None) + .expect_err("must require token or PROXY_PUBLIC"); + assert!(matches!(err, BootError::NonLoopbackRequiresAuth(_))); + } + + #[test] + fn proxy_public_allows_non_loopback_without_token() { + let addr: SocketAddr = "0.0.0.0:8788".parse().unwrap(); + assert_eq!( + resolve_auth_token(&addr, "0.0.0.0:8788", true, None).expect("public"), + None + ); + // Public mode ignores a present token — product path is open. + assert_eq!( + resolve_auth_token(&addr, "0.0.0.0:8788", true, Some("unused")).expect("public"), + None + ); + } + + #[test] + fn loopback_allows_missing_token() { + let addr: SocketAddr = "127.0.0.1:8788".parse().unwrap(); + assert_eq!( + resolve_auth_token(&addr, "127.0.0.1:8788", false, None).expect("loopback"), + None + ); + } + + #[test] + fn non_loopback_with_token_keeps_bearer() { + let addr: SocketAddr = "0.0.0.0:8788".parse().unwrap(); + assert_eq!( + resolve_auth_token(&addr, "0.0.0.0:8788", false, Some("secret")).expect("auth"), + Some("secret".into()) + ); + } +} diff --git a/crates/buzz-core/src/markets.rs b/crates/buzz-core/src/markets.rs index 5bc223a1654..afa40630d5c 100644 --- a/crates/buzz-core/src/markets.rs +++ b/crates/buzz-core/src/markets.rs @@ -82,6 +82,12 @@ pub enum MarketsError { (required env / public host, no localhost default)" )] AvnuProxyUrlLoopback, + /// Custom non-product proxy needs a process-env Bearer (never baked). + #[error( + "AVNU_PROXY_AUTH_TOKEN is required for non-product proxy /rpc \ + (runtime-only from process env; never bake into the client)" + )] + AvnuProxyAuthTokenRequired, /// `place_bet` call batch failed validation. #[error("place_bet call batch rejected: {0}")] InvalidBetBatch(String), @@ -247,6 +253,44 @@ pub fn resolve_avnu_proxy_url_from(raw: Option<&str>) -> Result bool { + proxy_base.trim_end_matches('/') == PRODUCT_AVNU_PROXY_URL.trim_end_matches('/') +} + +/// Bearer for proxy `/rpc`, or `None` on the product host (no token, no header). +/// +/// `token_env` is the raw `AVNU_PROXY_AUTH_TOKEN` value (tests pass it +/// explicitly). Product URL never requires or returns a token — even if +/// `token_env` is set — so packaged builds never send Bearer by accident. +pub fn avnu_proxy_bearer_token_from( + proxy_base: &str, + token_env: Option<&str>, +) -> Result, MarketsError> { + if is_product_avnu_proxy_url(proxy_base) { + return Ok(None); + } + let token = token_env + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(str::to_string) + .ok_or(MarketsError::AvnuProxyAuthTokenRequired)?; + Ok(Some(token)) +} + +/// Read `AVNU_PROXY_AUTH_TOKEN` from process env (never baked into the binary). +pub fn avnu_proxy_bearer_token(proxy_base: &str) -> Result, MarketsError> { + avnu_proxy_bearer_token_from( + proxy_base, + std::env::var("AVNU_PROXY_AUTH_TOKEN").ok().as_deref(), + ) +} + /// One Starknet call as hex strings (frontend / JSON-RPC shape). #[derive(Debug, Clone, PartialEq, Eq)] pub struct BetCallHex { @@ -578,6 +622,41 @@ mod tests { ); } + #[test] + fn product_avnu_proxy_needs_no_auth_token() { + assert!(is_product_avnu_proxy_url(PRODUCT_AVNU_PROXY_URL)); + assert!(is_product_avnu_proxy_url(&format!( + "{PRODUCT_AVNU_PROXY_URL}/" + ))); + assert_eq!( + avnu_proxy_bearer_token_from(PRODUCT_AVNU_PROXY_URL, None).unwrap(), + None + ); + // Stale token must not force a Bearer header on the product path. + assert_eq!( + avnu_proxy_bearer_token_from(PRODUCT_AVNU_PROXY_URL, Some("ignored")).unwrap(), + None + ); + } + + #[test] + fn custom_non_loopback_avnu_proxy_requires_auth_token() { + let custom = "https://paymaster.example.com"; + assert!(!is_product_avnu_proxy_url(custom)); + assert_eq!( + avnu_proxy_bearer_token_from(custom, None), + Err(MarketsError::AvnuProxyAuthTokenRequired) + ); + assert_eq!( + avnu_proxy_bearer_token_from(custom, Some(" ")), + Err(MarketsError::AvnuProxyAuthTokenRequired) + ); + assert_eq!( + avnu_proxy_bearer_token_from(custom, Some("test-only-not-a-real-secret")).unwrap(), + Some("test-only-not-a-real-secret".into()) + ); + } + #[test] fn validated_bet_batch_rebuilds_fee_first() { let token_amount = 1_000_000u128; diff --git a/desktop/src-tauri/src/commands/markets.rs b/desktop/src-tauri/src/commands/markets.rs index 77c70be1afa..b49a97b4090 100644 --- a/desktop/src-tauri/src/commands/markets.rs +++ b/desktop/src-tauri/src/commands/markets.rs @@ -10,9 +10,9 @@ use crate::app_state::AppState; use buzz_core_pkg::markets::{ - assert_fee_is_first_call, assert_markets_signing_keyring, betting_halted_by_remaining_blocks, - build_validated_bet_batch, markets_signing_keyring_name, resolve_avnu_proxy_url, - resolve_indexer_url, BetCallHex, NOSTR_ACCOUNT_CLASS_HASH, + assert_fee_is_first_call, assert_markets_signing_keyring, avnu_proxy_bearer_token, + betting_halted_by_remaining_blocks, build_validated_bet_batch, markets_signing_keyring_name, + resolve_avnu_proxy_url, resolve_indexer_url, BetCallHex, NOSTR_ACCOUNT_CLASS_HASH, }; use buzz_core_pkg::outside_execution::{ any_caller, felt_from_hex, selector_from_name, Felt, OutsideCall, OutsideExecution, @@ -346,19 +346,16 @@ async fn avnu_rpc(method: &str, params: Value) -> Result { "method": method, "params": params, }); - // `avnu_proxy_url` already refuses loopback; non-loopback /rpc requires Bearer. - let url = format!("{}/rpc", avnu_proxy_url()?); - let token = std::env::var("AVNU_PROXY_AUTH_TOKEN") - .map(|t| t.trim().to_string()) - .ok() - .filter(|t| !t.is_empty()) - .ok_or_else(|| { - "AVNU_PROXY_AUTH_TOKEN is required for non-loopback proxy /rpc \ - (runtime-only from process env; never bake into the client)" - .to_string() - })?; + // `avnu_proxy_url` already refuses loopback. Product host needs no Bearer; + // custom non-product non-loopback still requires AVNU_PROXY_AUTH_TOKEN. + let base = avnu_proxy_url()?; + let url = format!("{base}/rpc"); + let bearer = avnu_proxy_bearer_token(&base).map_err(|e| e.to_string())?; let client = reqwest::Client::new(); - let req = client.post(&url).json(&body).bearer_auth(token); + let mut req = client.post(&url).json(&body); + if let Some(token) = bearer { + req = req.bearer_auth(token); + } let resp = req .send() .await diff --git a/desktop/src/features/markets/lib/avnuProxy.ts b/desktop/src/features/markets/lib/avnuProxy.ts index ef9d5611e5f..3489a186c09 100644 --- a/desktop/src/features/markets/lib/avnuProxy.ts +++ b/desktop/src/features/markets/lib/avnuProxy.ts @@ -6,7 +6,8 @@ * — loopback was local-only and must not ship. * * Never read or ship `AVNU_API_KEY` / proxy auth tokens here. UI copy must not - * surface L2 vocabulary. + * surface L2 vocabulary. Product host needs no Bearer; custom proxies still do + * (handled in the Tauri `place_bet` path, not this module). */ import { PRODUCT_AVNU_PROXY_URL } from "./constants"; diff --git a/desktop/src/features/markets/lib/constants.ts b/desktop/src/features/markets/lib/constants.ts index 255a6981c97..4bf059f4e7d 100644 --- a/desktop/src/features/markets/lib/constants.ts +++ b/desktop/src/features/markets/lib/constants.ts @@ -21,6 +21,8 @@ export const PRODUCT_INDEXER_URL = "https://markets.bitcoinmarkets.app"; * default** — loopback (`127.0.0.1:8788`) must not ship. * * Never put `AVNU_API_KEY` or proxy auth tokens in this repo/client. + * Product desktop needs no `AVNU_PROXY_AUTH_TOKEN`; that env is only for a + * custom non-product `AVNU_PROXY_URL`. */ export const PRODUCT_AVNU_PROXY_URL = "https://paymaster.bitcoinmarkets.app"; diff --git a/docs/bitcoin-markets.md b/docs/bitcoin-markets.md index 0cde2ff37dd..25cc3550459 100644 --- a/docs/bitcoin-markets.md +++ b/docs/bitcoin-markets.md @@ -102,22 +102,23 @@ AVNU_PROXY_URL=https://paymaster.bitcoinmarkets.app # product host (default) stays off). Set `AVNU_API_KEY` only on the hosted proxy (never in the Tauri binary, repo, -or client). Non-loopback `/rpc` requires Bearer -`AVNU_PROXY_AUTH_TOKEN` in the desktop process env at runtime — sourced from -AWS secret `buzz-dev/avnu-proxy` (never committed; never baked into the -client). Missing token fails closed; the header is never silently omitted. +or client). Product desktop needs **no** `AVNU_PROXY_AUTH_TOKEN` / +`AVNU_PROXY_URL` / `INDEXER_URL` args — the packaged default talks to this +host without Bearer. `AVNU_PROXY_AUTH_TOKEN` is only for a custom +non-product `AVNU_PROXY_URL` (fail-closed; never baked into the client). Proxy process env (server-side only): ```text AVNU_API_KEY=… # from portal.avnu.fi — never commit AVNU_PAYMASTER_URL=https://starknet.paymaster.avnu.fi -BIND_ADDR=0.0.0.0:8788 # non-loopback in AWS; requires PROXY_AUTH_TOKEN -PROXY_AUTH_TOKEN=… # same secret material as AVNU_PROXY_AUTH_TOKEN +BIND_ADDR=0.0.0.0:8788 # non-loopback in AWS +PROXY_PUBLIC=1 # product-open /rpc (no Bearer); abuse = AVNU credits +# PROXY_AUTH_TOKEN=… # optional; required only when PROXY_PUBLIC is unset ``` -The proxy is **not** an unauthenticated open relay: there is no `CORS Any`, -and off-loopback requires Bearer. Health: +Live AWS sets `PROXY_PUBLIC=1`. Local/dev without that flag still requires +Bearer off-loopback. No `CORS Any`. Health: `GET https://paymaster.bitcoinmarkets.app/health` → `{"service":"buzz-avnu-proxy","status":"ok"}`. diff --git a/infra/aws/README.md b/infra/aws/README.md index 0ec531615c2..30840cc7612 100644 --- a/infra/aws/README.md +++ b/infra/aws/README.md @@ -359,14 +359,14 @@ It is an HTTP service on port **8788**, following the indexer ingress pattern (not paymaster egress-only): - Own security group: ingress from the ALB on 8788 only -- Own Secrets Manager secret (`buzz-dev/avnu-proxy`) for `AVNU_API_KEY` and - `PROXY_AUTH_TOKEN` — unmanaged version; **already exists in AWS** — import - on first enable (no `aws_secretsmanager_secret_version`) +- Own Secrets Manager secret (`buzz-dev/avnu-proxy`) for `AVNU_API_KEY` + (and optionally unused `PROXY_AUTH_TOKEN`) — unmanaged version; **already + exists in AWS** — import on first enable (no `aws_secretsmanager_secret_version`) - ALB HTTPS listener rule (priority 110): host-header `paymaster.bitcoinmarkets.app` → avnu-proxy TG - Health check `GET /health` → `{"status":"ok","service":"buzz-avnu-proxy"}` -- JSON-RPC: `POST /` and `POST /rpc` (Bearer `PROXY_AUTH_TOKEN` required — - `BIND_ADDR=0.0.0.0:8788` is non-loopback) +- JSON-RPC: `POST /` and `POST /rpc` with `PROXY_PUBLIC=1` (no Bearer — + product desktop needs no token; abuse control = AVNU credits) - Default listener action stays the relay The shared ACM certificate carries a SAN for `paymaster.bitcoinmarkets.app` diff --git a/infra/aws/avnu-proxy.tf b/infra/aws/avnu-proxy.tf index 58ce46fe339..d02ce34a768 100644 --- a/infra/aws/avnu-proxy.tf +++ b/infra/aws/avnu-proxy.tf @@ -109,8 +109,8 @@ variable "avnu_proxy_desired_count" { Defaults to 0 so enabling the stack can import the secret and create IAM roles without starting a task. Set to 1 once bootstrap is applied, the - secret is imported, and BIND_ADDR / PROXY_AUTH_TOKEN are confirmed in the - existing secret. + secret is imported, and BIND_ADDR / PROXY_PUBLIC / AVNU_API_KEY are + confirmed (PROXY_AUTH_TOKEN may remain in the secret unused). EOT type = number default = 0 @@ -305,7 +305,7 @@ resource "aws_lb_target_group" "avnu_proxy" { vpc_id = aws_vpc.main.id # Health: GET /health returns {"status":"ok","service":"buzz-avnu-proxy"}. - # JSON-RPC: POST / and POST /rpc (Bearer PROXY_AUTH_TOKEN when off-loopback). + # JSON-RPC: POST / and POST /rpc — product path uses PROXY_PUBLIC=1 (no Bearer). # Do NOT reuse the relay's /_readiness probe or health port 8080. health_check { enabled = true @@ -383,14 +383,16 @@ resource "aws_ecs_task_definition" "avnu_proxy" { ] environment = [ - # Non-loopback bind requires PROXY_AUTH_TOKEN (already in the secret). + # Product-open paymaster: desktop needs no Bearer. Abuse control = AVNU credits. + # PROXY_AUTH_TOKEN may remain in the secret unused; do not inject it here. { name = "BIND_ADDR", value = "0.0.0.0:${local.avnu_proxy_port}" }, + { name = "PROXY_PUBLIC", value = "1" }, ] # valueFrom with a trailing :key:: pulls one field out of the JSON secret. + # AVNU_API_KEY only — never put the key in terraform values / git. secrets = [ { name = "AVNU_API_KEY", valueFrom = "${aws_secretsmanager_secret.avnu_proxy[0].arn}:AVNU_API_KEY::" }, - { name = "PROXY_AUTH_TOKEN", valueFrom = "${aws_secretsmanager_secret.avnu_proxy[0].arn}:PROXY_AUTH_TOKEN::" }, ] logConfiguration = { diff --git a/infra/aws/dev.tfvars b/infra/aws/dev.tfvars index 138760f6adb..8301e31df5e 100644 --- a/infra/aws/dev.tfvars +++ b/infra/aws/dev.tfvars @@ -165,7 +165,7 @@ indexer_image = "618867225791.dkr.ecr.eu-west-3.amazonaws.com/buzz-dev-i # 3. Main stack applied (SG, IAM, TG, listener rule, Route53, service). # 4. avnu_proxy_enabled / desired_count pinned to match live state. # 5. Service at desired_count = 1 → https://paymaster.bitcoinmarkets.app -# (BIND_ADDR is 0.0.0.0:8788; PROXY_AUTH_TOKEN required off-loopback) +# (BIND_ADDR is 0.0.0.0:8788; PROXY_PUBLIC=1 — no Bearer on product /rpc) # # ACM already has a paymaster.bitcoinmarkets.app SAN. Route53 A alias + ALB # host-header rule exist when enabled.