Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
140 changes: 125 additions & 15 deletions crates/buzz-avnu-proxy/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//!
Expand All @@ -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 <token>`.
//! 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 <token>`.
//! 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
Expand Down Expand Up @@ -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}")]
Expand All @@ -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<Option<String>, 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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
);
}
}
79 changes: 79 additions & 0 deletions crates/buzz-core/src/markets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -247,6 +253,44 @@ pub fn resolve_avnu_proxy_url_from(raw: Option<&str>) -> Result<String, MarketsE
Ok(base.to_string())
}

/// True when `proxy_base` is the shipped product paymaster host.
///
/// Product desktop (`just desktop-standalone` / packaged app) talks to this
/// host with no `AVNU_PROXY_AUTH_TOKEN`. Custom `AVNU_PROXY_URL` stays
/// fail-closed.
#[must_use]
pub fn is_product_avnu_proxy_url(proxy_base: &str) -> 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<Option<String>, 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<Option<String>, 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 {
Expand Down Expand Up @@ -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;
Expand Down
27 changes: 12 additions & 15 deletions desktop/src-tauri/src/commands/markets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -346,19 +346,16 @@ async fn avnu_rpc(method: &str, params: Value) -> Result<Value, String> {
"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
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/features/markets/lib/avnuProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/markets/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
17 changes: 9 additions & 8 deletions docs/bitcoin-markets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`.

Expand Down
Loading
Loading