Skip to content

Commit ca3139f

Browse files
committed
feat: token as header
- update readme - add tests - add token validation
1 parent e80621b commit ca3139f

6 files changed

Lines changed: 199 additions & 38 deletions

File tree

README.md

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ High-performance JSON-RPC reverse proxy written in Rust. Designed as a drop-in r
99
- **Smart caching** — method-aware TTLs (immutable data cached for 1 hour, chain-tip data for configurable TTL)
1010
- **Request coalescing** — deduplicates identical in-flight requests to reduce upstream load
1111
- **Stale block detection** — backends reporting blocks far behind the best known block are marked degraded
12-
- **URL path token auth** — optional token as URL path (like GetBlock) so clients that can only provide a URL (e.g. Bee) can authenticate
12+
- **URL path token auth** — optional token as URL path so clients that can only provide a URL (e.g. Bee) can authenticate
13+
- **Bearer token auth** — standard `Authorization: Bearer <token>` header support
1314
- **Batch JSON-RPC** — full support for batched requests
1415
- **Verbose mode** — toggle between detailed debug logs and quiet production logging
1516
- **Docker ready** — multi-arch image with built-in `HEALTHCHECK`
@@ -24,7 +25,8 @@ Pre-built binaries for Linux, macOS, and Windows are attached to each [GitHub Re
2425
# Example: Linux amd64
2526
curl -LO https://github.com/MetaProvide/rpcproxy/releases/latest/download/rpcproxy-linux-amd64
2627
chmod +x rpcproxy-linux-amd64
27-
./rpcproxy-linux-amd64 --targets https://rpc.gnosis.gateway.fm --verbose
28+
mv rpcproxy-linux-amd64 rpcproxy
29+
./rpcproxy --targets https://rpc.gnosis.gateway.fm --verbose
2830
```
2931

3032
### Docker
@@ -94,27 +96,24 @@ services:
9496
9597
| Endpoint | Method | Auth | Description |
9698
|----------|--------|------|-------------|
97-
| `POST /` | POST | No | JSON-RPC proxy when no token is set |
98-
| `POST /<token>` | POST | Yes | JSON-RPC proxy when `--token` is set |
99+
| `POST /` | POST | Bearer | JSON-RPC proxy when `--token` is set |
100+
| `POST /<token>` | POST | Path | JSON-RPC proxy when `--token` is set |
99101
| `/health` | GET | No | Returns `200 ok` if ≥1 backend has real block data, else `503` |
100102
| `/readiness` | GET | Bearer | JSON response with backend details and overall status |
101103
| `/status` | GET | Bearer | Detailed JSON: all backends, states, request counts, cache stats |
102104

103105
### Authentication
104106

105-
When `--token` is set, the token becomes a **URL path segment**. This design lets
106-
clients that can only provide a URL (like Bee nodes) authenticate without custom headers,
107-
similar to how [GetBlock](https://getblock.io) works:
107+
When `--token` is set, RPC requests can authenticate in two ways:
108108

109-
```text
110-
# No token set → open access
111-
curl -X POST http://localhost:9000 -d '{...}'
112-
113-
# Token set → must use path
109+
**1. URL Path Token**
110+
```bash
114111
curl -X POST http://localhost:9000/my-secret-token -d '{...}'
112+
```
115113

116-
# Without token path → 401 Unauthorized
117-
curl -X POST http://localhost:9000 -d '{...}'
114+
**2. Bearer Header**
115+
```bash
116+
curl -X POST http://localhost:9000 -H "Authorization: Bearer my-secret-token" -d '{...}'
118117
```
119118

120119
The `/health` endpoint is **not** protected (for Docker HEALTHCHECK).

src/config.rs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ pub struct Config {
3232
#[arg(long, env = "RPCPROXY_CACHE_MAX_SIZE", default_value = "10000")]
3333
pub cache_max_size: u64,
3434

35-
/// Bearer token for authenticating RPC requests. If set, all RPC requests
36-
/// must be sent to `POST /<token>`. The `/readiness` and `/status` endpoints
37-
/// require `Authorization: Bearer <token>`. The `/health` endpoint is not protected.
35+
/// Token for authenticating requests. If set, RPC requests require either
36+
/// the token in the URL path (`POST /<token>`) or a Bearer header
37+
/// (`Authorization: Bearer <token>`). The `/readiness` and `/status` endpoints
38+
/// require the Bearer header. The `/health` endpoint is not protected.
39+
/// Token must contain only alphanumeric, -, _, ., or ~ characters.
3840
#[arg(long, env = "RPCPROXY_TOKEN")]
3941
pub token: Option<String>,
4042

@@ -44,3 +46,20 @@ pub struct Config {
4446
#[arg(short, long, env = "RPCPROXY_VERBOSE", default_value = "false")]
4547
pub verbose: bool,
4648
}
49+
50+
pub fn validate_token(token: &str) -> Result<(), String> {
51+
if token.is_empty() {
52+
return Err("token cannot be empty".to_string());
53+
}
54+
let invalid: String = token
55+
.chars()
56+
.filter(|c| !c.is_ascii_alphanumeric() && !matches!(c, '-' | '_' | '.' | '~'))
57+
.collect();
58+
if !invalid.is_empty() {
59+
return Err(format!(
60+
"token contains invalid characters: '{}'. Only alphanumeric, -, _, ., and ~ are allowed",
61+
invalid
62+
));
63+
}
64+
Ok(())
65+
}

src/handler/rpc.rs

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,52 @@
11
use std::sync::Arc;
22

33
use axum::extract::{Path, State};
4-
use axum::http::StatusCode;
4+
use axum::http::{HeaderMap, StatusCode};
55
use axum::response::{IntoResponse, Json};
66
use tracing::{error, warn};
77

88
use crate::cache::policy as cache_policy;
99
use crate::jsonrpc::{JsonRpcBody, JsonRpcRequest, JsonRpcResponse};
1010

1111
use super::AppState;
12+
use super::auth::check_bearer_token;
1213

1314
/// RPC handler for token-authenticated path: POST /<token>
1415
pub async fn token_rpc_handler(
1516
State(state): State<AppState>,
1617
Path(path_token): Path<String>,
18+
headers: HeaderMap,
1719
body: String,
1820
) -> impl IntoResponse {
19-
if let Some(expected_token) = &state.token
20-
&& path_token != *expected_token
21-
{
22-
warn!("unauthorized RPC request (bad token path)");
23-
return (
24-
StatusCode::UNAUTHORIZED,
25-
Json(
26-
serde_json::to_value(JsonRpcResponse::error(
27-
serde_json::Value::Null,
28-
-32000,
29-
"Unauthorized",
30-
))
31-
.unwrap(),
32-
),
33-
);
21+
if let Some(expected_token) = &state.token {
22+
let path_valid = path_token == *expected_token;
23+
let header_valid = check_bearer_token(&state, &headers);
24+
if !path_valid && !header_valid {
25+
warn!("unauthorized RPC request (bad token path and no valid bearer)");
26+
return (
27+
StatusCode::UNAUTHORIZED,
28+
Json(
29+
serde_json::to_value(JsonRpcResponse::error(
30+
serde_json::Value::Null,
31+
-32000,
32+
"Unauthorized",
33+
))
34+
.unwrap(),
35+
),
36+
);
37+
}
3438
}
3539
dispatch_rpc(&state, body).await
3640
}
3741

3842
/// RPC handler for open access: POST /
39-
pub async fn open_rpc_handler(State(state): State<AppState>, body: String) -> impl IntoResponse {
40-
if state.token.is_some() {
41-
warn!("unauthorized RPC request (missing token path)");
43+
pub async fn open_rpc_handler(
44+
State(state): State<AppState>,
45+
headers: HeaderMap,
46+
body: String,
47+
) -> impl IntoResponse {
48+
if state.token.is_some() && !check_bearer_token(&state, &headers) {
49+
warn!("unauthorized RPC request (missing or bad bearer token)");
4250
return (
4351
StatusCode::UNAUTHORIZED,
4452
Json(

src/main.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use clap::Parser;
77
use tracing::info;
88

99
use rpcproxy::cache::RpcCache;
10-
use rpcproxy::config::Config;
10+
use rpcproxy::config::{Config, validate_token};
1111
use rpcproxy::handler;
1212
use rpcproxy::handler::AppState;
1313
use rpcproxy::health;
@@ -17,6 +17,13 @@ use rpcproxy::upstream::UpstreamManager;
1717
async fn main() {
1818
let config = Config::parse();
1919

20+
if let Some(ref token) = config.token
21+
&& let Err(e) = validate_token(token)
22+
{
23+
eprintln!("error: invalid token: {e}");
24+
std::process::exit(1);
25+
}
26+
2027
let log_level = if config.verbose {
2128
"debug,hyper=info,reqwest=info"
2229
} else {

tests/config.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use clap::Parser;
2-
use rpcproxy::config::Config;
2+
use rpcproxy::config::{Config, validate_token};
33

44
#[test]
55
fn defaults() {
@@ -40,3 +40,30 @@ fn cli_overrides() {
4040
assert_eq!(config.cache_max_size, 50000);
4141
assert_eq!(config.token, Some("secret123".to_string()));
4242
}
43+
44+
#[test]
45+
fn token_validation_accepts_valid_tokens() {
46+
assert!(validate_token("simple").is_ok());
47+
assert!(validate_token("with-dash").is_ok());
48+
assert!(validate_token("with_underscore").is_ok());
49+
assert!(validate_token("with.dot").is_ok());
50+
assert!(validate_token("with~tilde").is_ok());
51+
assert!(validate_token("ABC123xyz-_.~test").is_ok());
52+
assert!(validate_token("1234567890").is_ok());
53+
}
54+
55+
#[test]
56+
fn token_validation_rejects_empty() {
57+
assert!(validate_token("").is_err());
58+
}
59+
60+
#[test]
61+
fn token_validation_rejects_invalid_chars() {
62+
assert!(validate_token("has space").is_err());
63+
assert!(validate_token("has/slash").is_err());
64+
assert!(validate_token("has?question").is_err());
65+
assert!(validate_token("has#hash").is_err());
66+
assert!(validate_token("has!bang").is_err());
67+
assert!(validate_token("has@at").is_err());
68+
assert!(validate_token("has%percent").is_err());
69+
}

tests/handler.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,107 @@ async fn auth_rejects_open_endpoint_when_token_set() {
7575
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7676
}
7777

78+
/// Token-protected proxy accepts requests on open endpoint with valid Bearer header.
79+
#[tokio::test]
80+
async fn auth_accepts_bearer_header_on_open_endpoint() {
81+
let server = MockServer::start().await;
82+
Mock::given(method("POST"))
83+
.respond_with(ResponseTemplate::new(200).set_body_json(ok_response("0xabc")))
84+
.mount(&server)
85+
.await;
86+
87+
let app = setup(&server.uri(), Some("secret")).await;
88+
89+
let resp = app
90+
.oneshot(
91+
Request::builder()
92+
.method("POST")
93+
.uri("/")
94+
.header("content-type", "application/json")
95+
.header("authorization", "Bearer secret")
96+
.body(Body::from(
97+
r#"{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}"#,
98+
))
99+
.unwrap(),
100+
)
101+
.await
102+
.unwrap();
103+
104+
assert_eq!(resp.status(), StatusCode::OK);
105+
let body: serde_json::Value = serde_json::from_slice(
106+
&axum::body::to_bytes(resp.into_body(), usize::MAX)
107+
.await
108+
.unwrap(),
109+
)
110+
.unwrap();
111+
assert_eq!(body["result"], "0xabc");
112+
}
113+
114+
/// Token-protected proxy rejects open endpoint with wrong Bearer header.
115+
#[tokio::test]
116+
async fn auth_rejects_wrong_bearer_header_on_open_endpoint() {
117+
let server = MockServer::start().await;
118+
Mock::given(method("POST"))
119+
.respond_with(ResponseTemplate::new(200).set_body_json(ok_response("0x1")))
120+
.mount(&server)
121+
.await;
122+
123+
let app = setup(&server.uri(), Some("secret")).await;
124+
125+
let resp = app
126+
.oneshot(
127+
Request::builder()
128+
.method("POST")
129+
.uri("/")
130+
.header("content-type", "application/json")
131+
.header("authorization", "Bearer wrong")
132+
.body(Body::from(
133+
r#"{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}"#,
134+
))
135+
.unwrap(),
136+
)
137+
.await
138+
.unwrap();
139+
140+
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
141+
}
142+
143+
/// Token-protected proxy accepts requests on token path with valid Bearer header (even if path token is wrong).
144+
#[tokio::test]
145+
async fn auth_accepts_bearer_header_on_token_path() {
146+
let server = MockServer::start().await;
147+
Mock::given(method("POST"))
148+
.respond_with(ResponseTemplate::new(200).set_body_json(ok_response("0xdef")))
149+
.mount(&server)
150+
.await;
151+
152+
let app = setup(&server.uri(), Some("secret")).await;
153+
154+
let resp = app
155+
.oneshot(
156+
Request::builder()
157+
.method("POST")
158+
.uri("/wrong-token")
159+
.header("content-type", "application/json")
160+
.header("authorization", "Bearer secret")
161+
.body(Body::from(
162+
r#"{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}"#,
163+
))
164+
.unwrap(),
165+
)
166+
.await
167+
.unwrap();
168+
169+
assert_eq!(resp.status(), StatusCode::OK);
170+
let body: serde_json::Value = serde_json::from_slice(
171+
&axum::body::to_bytes(resp.into_body(), usize::MAX)
172+
.await
173+
.unwrap(),
174+
)
175+
.unwrap();
176+
assert_eq!(body["result"], "0xdef");
177+
}
178+
78179
/// Token-protected proxy rejects requests with wrong token path.
79180
#[tokio::test]
80181
async fn auth_rejects_wrong_token_path() {

0 commit comments

Comments
 (0)