Skip to content

Commit 4ad90dc

Browse files
committed
Prevent relay/gateway self-loop in payjoin-service
OHTTP privacy guarantees rely on the assumption that the relay and gateway operate independently from each other. The payjoin service runs both OHTTP relay and the directory gateway in the same process, so we make a best-effort attempt to detect requests from the same instance via a sentinel header. This check eliminates a potential footgun for service operators and payjoin implementers.
1 parent ffb4459 commit 4ad90dc

File tree

10 files changed

+181
-24
lines changed

10 files changed

+181
-24
lines changed

Cargo-minimal.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2518,6 +2518,7 @@ dependencies = [
25182518
"http-body-util",
25192519
"hyper",
25202520
"hyper-util",
2521+
"ohttp-relay",
25212522
"payjoin",
25222523
"prometheus",
25232524
"rand 0.8.5",
@@ -2562,6 +2563,7 @@ dependencies = [
25622563
"config",
25632564
"ohttp-relay",
25642565
"payjoin-directory",
2566+
"rand 0.8.5",
25652567
"rustls 0.23.31",
25662568
"serde",
25672569
"tokio",

Cargo-recent.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2518,6 +2518,7 @@ dependencies = [
25182518
"http-body-util",
25192519
"hyper",
25202520
"hyper-util",
2521+
"ohttp-relay",
25212522
"payjoin",
25222523
"prometheus",
25232524
"rand 0.8.5",
@@ -2562,6 +2563,7 @@ dependencies = [
25622563
"config",
25632564
"ohttp-relay",
25642565
"payjoin-directory",
2566+
"rand 0.8.5",
25652567
"rustls 0.23.31",
25662568
"serde",
25672569
"tokio",

ohttp-relay/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ _test-util = []
2323
byteorder = "1.5.0"
2424
bytes = "1.10.1"
2525
futures = { version = "0.3.31", optional = true }
26+
hex = { package = "hex-conservative", version = "0.1.1" }
2627
http = "1.3.1"
2728
http-body-util = "0.1.3"
2829
hyper = { version = "1.6.0", features = ["http1", "server"] }
@@ -48,7 +49,6 @@ tracing = "0.1.41"
4849
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
4950

5051
[dev-dependencies]
51-
hex = { package = "hex-conservative", version = "0.1.1" }
5252
mockito = "1.7.0"
5353
rcgen = "0.12"
5454
reqwest = { version = "0.12.23", default-features = false, features = [

ohttp-relay/src/lib.rs

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ mod gateway_prober;
3434
#[cfg(feature = "_test-util")]
3535
pub mod gateway_prober;
3636
mod gateway_uri;
37+
pub mod sentinel;
38+
pub use sentinel::{InvalidHeader, SentinelTag};
39+
3740
use crate::error::{BoxError, Error};
3841

3942
#[cfg(any(feature = "connect-bootstrap", feature = "ws-bootstrap"))]
@@ -52,7 +55,7 @@ pub async fn listen_tcp(
5255
let addr = SocketAddr::from(([0, 0, 0, 0], port));
5356
let listener = TcpListener::bind(addr).await?;
5457
println!("OHTTP relay listening on tcp://{}", addr);
55-
ohttp_relay(listener, RelayConfig::new_with_default_client(gateway_origin)).await
58+
ohttp_relay(listener, RelayConfig::new_with_default_client(gateway_origin, None)).await
5659
}
5760

5861
#[instrument]
@@ -62,7 +65,7 @@ pub async fn listen_socket(
6265
) -> Result<tokio::task::JoinHandle<Result<(), BoxError>>, BoxError> {
6366
let listener = UnixListener::bind(socket_path)?;
6467
info!("OHTTP relay listening on socket: {}", socket_path);
65-
ohttp_relay(listener, RelayConfig::new_with_default_client(gateway_origin)).await
68+
ohttp_relay(listener, RelayConfig::new_with_default_client(gateway_origin, None)).await
6669
}
6770

6871
#[cfg(feature = "_test-util")]
@@ -73,7 +76,7 @@ pub async fn listen_tcp_on_free_port(
7376
let listener = tokio::net::TcpListener::bind("[::]:0").await?;
7477
let port = listener.local_addr()?.port();
7578
println!("OHTTP relay binding to port {}", listener.local_addr()?);
76-
let config = RelayConfig::new(default_gateway, root_store);
79+
let config = RelayConfig::new(default_gateway, root_store, None);
7780
let handle = ohttp_relay(listener, config).await?;
7881
Ok((port, handle))
7982
}
@@ -83,17 +86,25 @@ struct RelayConfig {
8386
default_gateway: GatewayUri,
8487
client: HttpClient,
8588
prober: Prober,
89+
sentinel_tag: Option<SentinelTag>,
8690
}
8791

8892
impl RelayConfig {
89-
fn new_with_default_client(default_gateway: GatewayUri) -> Self {
90-
Self::new(default_gateway, HttpClient::default())
93+
fn new_with_default_client(
94+
default_gateway: GatewayUri,
95+
sentinel_tag: Option<SentinelTag>,
96+
) -> Self {
97+
Self::new(default_gateway, HttpClient::default(), sentinel_tag)
9198
}
9299

93-
fn new(default_gateway: GatewayUri, into_client: impl Into<HttpClient>) -> Self {
100+
fn new(
101+
default_gateway: GatewayUri,
102+
into_client: impl Into<HttpClient>,
103+
sentinel_tag: Option<SentinelTag>,
104+
) -> Self {
94105
let client = into_client.into();
95106
let prober = Prober::new_with_client(client.clone());
96-
RelayConfig { default_gateway, client, prober }
107+
RelayConfig { default_gateway, client, prober, sentinel_tag }
97108
}
98109
}
99110

@@ -105,21 +116,24 @@ pub struct Service {
105116
impl Service {
106117
fn from_config(config: Arc<RelayConfig>) -> Self { Self { config } }
107118

108-
pub async fn new() -> Self {
119+
pub async fn new(sentinel_tag: SentinelTag) -> Self {
109120
// The default gateway is hardcoded because it is obsolete and required only for backwards
110121
// compatibility.
111122
// The new mechanism for specifying a custom gateway is via RFC 9540 using
112123
// `/.well-known/ohttp-gateway` request paths.
113124
let gateway_origin = GatewayUri::from_str(DEFAULT_GATEWAY).expect("valid gateway uri");
114-
let config = RelayConfig::new_with_default_client(gateway_origin);
125+
let config = RelayConfig::new_with_default_client(gateway_origin, Some(sentinel_tag));
115126
config.prober.assert_opt_in(&config.default_gateway).await;
116127
Self { config: Arc::new(config) }
117128
}
118129

119130
#[cfg(feature = "_test-util")]
120-
pub async fn new_with_roots(root_store: rustls::RootCertStore) -> Self {
131+
pub async fn new_with_roots(
132+
root_store: rustls::RootCertStore,
133+
sentinel_tag: SentinelTag,
134+
) -> Self {
121135
let gateway_origin = GatewayUri::from_str(DEFAULT_GATEWAY).expect("valid gateway uri");
122-
let config = RelayConfig::new(gateway_origin, root_store);
136+
let config = RelayConfig::new(gateway_origin, root_store, Some(sentinel_tag));
123137
config.prober.assert_opt_in(&config.default_gateway).await;
124138
Self { config: Arc::new(config) }
125139
}
@@ -319,7 +333,8 @@ where
319333
B: hyper::body::Body<Data = Bytes> + Send + Debug + 'static,
320334
B::Error: Into<BoxError>,
321335
{
322-
let fwd_req = into_forward_req(req, gateway).await?;
336+
let fwd_req = into_forward_req(req, gateway, config.sentinel_tag.as_ref()).await?;
337+
323338
forward_request(fwd_req, config).await.map(|res| {
324339
let (parts, body) = res.into_parts();
325340
let boxed_body = BoxBody::new(body);
@@ -332,6 +347,7 @@ where
332347
async fn into_forward_req<B>(
333348
req: Request<B>,
334349
gateway_origin: GatewayUri,
350+
sentinel_tag: Option<&SentinelTag>,
335351
) -> Result<Request<BoxBody<Bytes, hyper::Error>>, Error>
336352
where
337353
B: hyper::body::Body<Data = Bytes> + Send + Debug + 'static,
@@ -359,6 +375,10 @@ where
359375
let bytes =
360376
body.collect().await.map_err(|e| Error::BadRequest(e.into().to_string()))?.to_bytes();
361377

378+
if let Some(tag) = sentinel_tag {
379+
builder = builder.header(sentinel::HEADER_NAME, tag.to_header_value());
380+
}
381+
362382
builder.body(full(bytes)).map_err(|e| Error::InternalServerError(Box::new(e)))
363383
}
364384

ohttp-relay/src/sentinel.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
use hex::{DisplayHex, FromHex};
2+
3+
/// HTTP header name for the sentinel tag.
4+
pub const HEADER_NAME: &str = "x-pj-sentinel";
5+
6+
#[derive(Debug)]
7+
pub struct InvalidHeader;
8+
9+
impl std::fmt::Display for InvalidHeader {
10+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11+
write!(f, "malformed sentinel header")
12+
}
13+
}
14+
15+
impl std::error::Error for InvalidHeader {}
16+
17+
/// A random 32-byte tag shared between relay and gateway for same-instance detection.
18+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19+
pub struct SentinelTag([u8; 32]);
20+
21+
impl SentinelTag {
22+
/// Creates a new sentinel tag from raw bytes.
23+
pub fn new(bytes: [u8; 32]) -> Self { Self(bytes) }
24+
25+
/// Returns the tag as a hex string for use in HTTP headers.
26+
pub fn to_header_value(&self) -> String { self.0.to_lower_hex_string() }
27+
}
28+
29+
/// Verifies a sentinel header value against a tag.
30+
///
31+
/// Note that incoming requests should be **rejected** when this function returns `Ok(true)`,
32+
/// as that would indicate the relay and gateway are the same instance.
33+
pub fn verify(tag: &SentinelTag, header_value: &str) -> Result<bool, InvalidHeader> {
34+
let header_bytes = <[u8; 32]>::from_hex(header_value).map_err(|_| InvalidHeader)?;
35+
Ok(tag.0 == header_bytes)
36+
}
37+
38+
#[cfg(test)]
39+
mod tests {
40+
use super::*;
41+
42+
#[test]
43+
fn same_tag_matches() {
44+
let tag = SentinelTag::new([0u8; 32]);
45+
let header = tag.to_header_value();
46+
assert!(verify(&tag, &header).unwrap(), "same tag should match");
47+
}
48+
49+
#[test]
50+
fn different_tag_does_not_match() {
51+
let tag1 = SentinelTag::new([0u8; 32]);
52+
let tag2 = SentinelTag::new([1u8; 32]);
53+
let header = tag1.to_header_value();
54+
assert!(!verify(&tag2, &header).unwrap(), "different tag should not match");
55+
}
56+
57+
#[test]
58+
fn malformed_header_returns_err() {
59+
let tag = SentinelTag::new([0u8; 32]);
60+
assert!(verify(&tag, "invalid").is_err(), "non-hex should error");
61+
assert!(verify(&tag, "abcd").is_err(), "wrong length should error");
62+
assert!(verify(&tag, "zz").is_err(), "invalid hex chars should error");
63+
}
64+
65+
#[test]
66+
fn header_format() {
67+
let tag = SentinelTag::new([0xab; 32]);
68+
let header = tag.to_header_value();
69+
70+
// Should be 64 hex characters (32 bytes)
71+
assert_eq!(header.len(), 64, "header should be 64 hex characters");
72+
assert!(header.chars().all(|c| c.is_ascii_hexdigit()), "header should be valid hex");
73+
assert_eq!(header, "ab".repeat(32), "header should match expected hex");
74+
}
75+
}

payjoin-directory/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ http-body-util = "0.1.3"
2929
hyper = { version = "1.6.0", features = ["http1", "server"] }
3030
hyper-util = { version = "0.1.16", features = ["tokio", "service"] }
3131
ohttp = { package = "bitcoin-ohttp", version = "0.6.0" }
32+
ohttp-relay = { path = "../ohttp-relay" }
3233
payjoin = { version = "1.0.0-rc.1", features = [
3334
"directory",
3435
], default-features = false }

payjoin-directory/src/lib.rs

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ use tracing::{debug, error, trace, warn};
2424
pub use crate::db::files::Db as FilesDb;
2525
use crate::db::Db;
2626
pub mod key_config;
27+
use ohttp_relay::SentinelTag;
28+
2729
pub use crate::key_config::*;
2830
use crate::metrics::Metrics;
2931

@@ -68,6 +70,7 @@ pub struct Service<D: Db> {
6870
db: D,
6971
ohttp: ohttp::Server,
7072
metrics: Metrics,
73+
sentinel_tag: Option<SentinelTag>,
7174
}
7275

7376
impl<D: Db, B> tower::Service<Request<B>> for Service<D>
@@ -91,8 +94,13 @@ where
9194
}
9295

9396
impl<D: Db> Service<D> {
94-
pub fn new(db: D, ohttp: ohttp::Server, metrics: Metrics) -> Self {
95-
Self { db, ohttp, metrics }
97+
pub fn new(
98+
db: D,
99+
ohttp: ohttp::Server,
100+
metrics: Metrics,
101+
sentinel_tag: Option<SentinelTag>,
102+
) -> Self {
103+
Self { db, ohttp, metrics, sentinel_tag }
96104
}
97105

98106
#[cfg(feature = "_manual-tls")]
@@ -194,12 +202,20 @@ impl<D: Db> Service<D> {
194202

195203
let path_segments: Vec<&str> = path.split('/').collect();
196204
debug!("Service::serve_request: {:?}", &path_segments);
205+
206+
let sentinel_header = parts
207+
.headers
208+
.get(ohttp_relay::sentinel::HEADER_NAME)
209+
.and_then(|v| v.to_str().ok())
210+
.map(String::from);
211+
197212
let mut response = match (parts.method, path_segments.as_slice()) {
198213
(Method::POST, ["", ".well-known", "ohttp-gateway"]) =>
199-
self.handle_ohttp_gateway(body).await,
214+
self.handle_ohttp_gateway(body, sentinel_header.as_deref()).await,
200215
(Method::GET, ["", ".well-known", "ohttp-gateway"]) =>
201216
self.handle_ohttp_gateway_get(&query).await,
202-
(Method::POST, ["", ""]) => self.handle_ohttp_gateway(body).await,
217+
(Method::POST, ["", ""]) =>
218+
self.handle_ohttp_gateway(body, sentinel_header.as_deref()).await,
203219
(Method::GET, ["", "ohttp-keys"]) => self.get_ohttp_keys().await,
204220
(Method::POST, ["", id]) => self.post_fallback_v1(id, query, body).await,
205221
(Method::GET, ["", "health"]) => health_check().await,
@@ -217,17 +233,40 @@ impl<D: Db> Service<D> {
217233
async fn handle_ohttp_gateway<B>(
218234
&self,
219235
body: B,
236+
sentinel_header: Option<&str>,
220237
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, HandlerError>
221238
where
222239
B: Body<Data = Bytes> + Send + 'static,
223240
B::Error: Into<BoxError>,
224241
{
225-
// decapsulate
226242
let ohttp_body = body
227243
.collect()
228244
.await
229245
.map_err(|e| HandlerError::BadRequest(anyhow::anyhow!(e.into())))?
230246
.to_bytes();
247+
248+
// Best-effort validation that the relay and gateway aren't on the same
249+
// payjoin-service instance
250+
if let Some(tag) = &self.sentinel_tag {
251+
if let Some(header_value) = sentinel_header {
252+
match ohttp_relay::sentinel::verify(tag, header_value) {
253+
Ok(false) => {} // Allow
254+
Ok(true) => {
255+
warn!("Rejected OHTTP request from same-instance relay");
256+
return Err(HandlerError::Forbidden(anyhow::anyhow!(
257+
"Relay and gateway must be operated by different entities"
258+
)));
259+
}
260+
Err(_) => {
261+
warn!("Rejected OHTTP request with malformed sentinel header");
262+
return Err(HandlerError::BadRequest(anyhow::anyhow!(
263+
"Malformed sentinel header"
264+
)));
265+
}
266+
}
267+
}
268+
}
269+
231270
let (bhttp_req, res_ctx) = self
232271
.ohttp
233272
.decapsulate(&ohttp_body)
@@ -577,6 +616,7 @@ enum HandlerError {
577616
SenderGone(anyhow::Error),
578617
OhttpKeyRejection(anyhow::Error),
579618
BadRequest(anyhow::Error),
619+
Forbidden(anyhow::Error),
580620
}
581621

582622
impl HandlerError {
@@ -609,6 +649,10 @@ impl HandlerError {
609649
warn!("Bad request: {}", e);
610650
*res.status_mut() = StatusCode::BAD_REQUEST
611651
}
652+
HandlerError::Forbidden(e) => {
653+
warn!("Forbidden: {}", e);
654+
*res.status_mut() = StatusCode::FORBIDDEN
655+
}
612656
};
613657

614658
res

payjoin-directory/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ async fn main() -> Result<(), BoxError> {
3030
.await
3131
.expect("Failed to initialize persistent storage");
3232

33-
let service = Service::new(db, ohttp.into(), metrics);
33+
let service = Service::new(db, ohttp.into(), metrics, None);
3434

3535
// Start metrics server in the background
3636
if let Some(addr) = config.metrics_listen_addr {

payjoin-service/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ _manual-tls = ["dep:axum-server", "dep:rustls", "ohttp-relay/_test-util"]
2020
[dependencies]
2121
anyhow = "1.0"
2222
axum = "0.8"
23+
rand = "0.8"
2324
axum-server = { version = "0.7", features = [
2425
"tls-rustls-no-provider",
2526
], optional = true }

0 commit comments

Comments
 (0)