Skip to content

Commit 4c450cc

Browse files
committed
Add blocked_ips config and CIDR matching
Allow operators to block specific IP addresses or CIDR ranges independently of geographic region. Bare IPs ("192.0.2.1") and CIDR notation ("192.0.2.0/24") are both accepted in the config.
1 parent 9f366c2 commit 4c450cc

8 files changed

Lines changed: 80 additions & 14 deletions

File tree

Cargo-minimal.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2798,6 +2798,7 @@ dependencies = [
27982798
"clap",
27992799
"config",
28002800
"flate2",
2801+
"ipnet",
28012802
"maxminddb",
28022803
"ohttp-relay",
28032804
"opentelemetry",

Cargo-recent.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2798,6 +2798,7 @@ dependencies = [
27982798
"clap",
27992799
"config",
28002800
"flate2",
2801+
"ipnet",
28012802
"maxminddb",
28022803
"ohttp-relay",
28032804
"opentelemetry",

payjoin-mailroom/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ acme = [
2222
"dep:rustls",
2323
"dep:tokio-stream",
2424
]
25-
access-control = ["dep:flate2", "dep:maxminddb", "dep:reqwest"]
25+
access-control = ["dep:flate2", "dep:ipnet", "dep:maxminddb", "dep:reqwest"]
2626
telemetry = ["dep:opentelemetry-otlp"]
2727

2828
[dependencies]
@@ -34,6 +34,7 @@ axum-server = { version = "0.8", features = [
3434
clap = { version = "4.5", features = ["derive", "env"] }
3535
config = "0.15"
3636
flate2 = { version = "1.1", optional = true }
37+
ipnet = { version = "2", optional = true }
3738
maxminddb = { version = "0.27", optional = true }
3839
ohttp-relay = { path = "../ohttp-relay", features = ["bootstrap"] }
3940
opentelemetry = "0.31"

payjoin-mailroom/config.example.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,6 @@
3737

3838
# ISO 3166-1 alpha-2 country codes whose requests should be blocked.
3939
# blocked_regions = ["CU", "IR", "KP", "SY"]
40+
41+
# IP addresses or CIDR ranges whose requests should be blocked.
42+
# blocked_ips = ["192.0.2.0/24", "2001:db8::1"]

payjoin-mailroom/src/access_control.rs

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ use maxminddb::PathElement;
66

77
use crate::config::AccessControlConfig;
88

9-
pub struct GeoIp {
9+
pub struct IpFilter {
1010
geo_reader: Option<maxminddb::Reader<Vec<u8>>>,
1111
blocked_regions: HashSet<String>,
12+
blocked_ips: Vec<ipnet::IpNet>,
1213
}
1314

14-
impl GeoIp {
15+
impl IpFilter {
1516
pub async fn from_config(
1617
config: &AccessControlConfig,
1718
storage_dir: &Path,
@@ -42,11 +43,30 @@ impl GeoIp {
4243

4344
let blocked_regions = config.blocked_regions.iter().cloned().collect();
4445

45-
Ok(Self { geo_reader, blocked_regions })
46+
let blocked_ips = config
47+
.blocked_ips
48+
.iter()
49+
.map(|s| {
50+
s.parse::<ipnet::IpNet>().or_else(|_| {
51+
// Accept bare IP addresses without CIDR prefix length
52+
Ok(ipnet::IpNet::from(s.parse::<IpAddr>()?))
53+
})
54+
})
55+
.collect::<Result<Vec<_>, anyhow::Error>>()?;
56+
57+
Ok(Self { geo_reader, blocked_regions, blocked_ips })
4658
}
4759

48-
/// Returns `true` if the IP is allowed. Fail-open on lookup errors.
60+
/// Returns `true` if the IP is allowed. Fail-open on GeoIP lookup errors.
4961
pub fn check_ip(&self, ip: IpAddr) -> bool {
62+
if self.blocked_ips.iter().any(|net| net.contains(&ip)) {
63+
return false;
64+
}
65+
66+
self.check_geoip(ip)
67+
}
68+
69+
fn check_geoip(&self, ip: IpAddr) -> bool {
5070
let reader = match &self.geo_reader {
5171
Some(r) => r,
5272
None => return true,
@@ -165,14 +185,19 @@ mod tests {
165185

166186
#[test]
167187
fn check_ip_allows_when_no_geo_reader() {
168-
let ac = GeoIp { geo_reader: None, blocked_regions: HashSet::new() };
188+
let ac =
189+
IpFilter { geo_reader: None, blocked_regions: HashSet::new(), blocked_ips: vec![] };
169190
assert!(ac.check_ip("1.2.3.4".parse().unwrap()));
170191
}
171192

172193
#[test]
173194
fn check_ip_allows_when_no_blocked_regions() {
174195
let reader = test_geo_reader();
175-
let ac = GeoIp { geo_reader: Some(reader), blocked_regions: HashSet::new() };
196+
let ac = IpFilter {
197+
geo_reader: Some(reader),
198+
blocked_regions: HashSet::new(),
199+
blocked_ips: vec![],
200+
};
176201
assert!(ac.check_ip("2.125.160.216".parse().unwrap()));
177202
}
178203

@@ -181,7 +206,7 @@ mod tests {
181206
let reader = test_geo_reader();
182207
// 2.125.160.216 is GB in the test database
183208
let blocked_regions: HashSet<String> = ["GB"].iter().map(|s| s.to_string()).collect();
184-
let ac = GeoIp { geo_reader: Some(reader), blocked_regions };
209+
let ac = IpFilter { geo_reader: Some(reader), blocked_regions, blocked_ips: vec![] };
185210
assert!(!ac.check_ip("2.125.160.216".parse().unwrap()));
186211
}
187212

@@ -190,19 +215,52 @@ mod tests {
190215
let reader = test_geo_reader();
191216
// 2.125.160.216 is GB in the test database
192217
let blocked_regions: HashSet<String> = ["US"].iter().map(|s| s.to_string()).collect();
193-
let ac = GeoIp { geo_reader: Some(reader), blocked_regions };
218+
let ac = IpFilter { geo_reader: Some(reader), blocked_regions, blocked_ips: vec![] };
194219
assert!(ac.check_ip("2.125.160.216".parse().unwrap()));
195220
}
196221

197222
#[test]
198223
fn check_ip_fail_open_on_unknown_ip() {
199224
let reader = test_geo_reader();
200225
let blocked_regions: HashSet<String> = ["US"].iter().map(|s| s.to_string()).collect();
201-
let ac = GeoIp { geo_reader: Some(reader), blocked_regions };
226+
let ac = IpFilter { geo_reader: Some(reader), blocked_regions, blocked_ips: vec![] };
202227
// 127.0.0.1 won't be in test DB
203228
assert!(ac.check_ip("127.0.0.1".parse().unwrap()));
204229
}
205230

231+
#[test]
232+
fn blocked_ips_blocks_exact_ipv4() {
233+
let blocked_ips = vec!["192.0.2.1/32".parse().unwrap()];
234+
let ac = IpFilter { geo_reader: None, blocked_regions: HashSet::new(), blocked_ips };
235+
assert!(!ac.check_ip("192.0.2.1".parse().unwrap()));
236+
assert!(ac.check_ip("192.0.2.2".parse().unwrap()));
237+
}
238+
239+
#[test]
240+
fn blocked_ips_blocks_exact_ipv6() {
241+
let blocked_ips = vec!["2001:db8::1/128".parse().unwrap()];
242+
let ac = IpFilter { geo_reader: None, blocked_regions: HashSet::new(), blocked_ips };
243+
assert!(!ac.check_ip("2001:db8::1".parse().unwrap()));
244+
assert!(ac.check_ip("2001:db8::2".parse().unwrap()));
245+
}
246+
247+
#[test]
248+
fn blocked_ips_blocks_cidr_range() {
249+
let blocked_ips = vec!["198.51.100.0/24".parse().unwrap()];
250+
let ac = IpFilter { geo_reader: None, blocked_regions: HashSet::new(), blocked_ips };
251+
assert!(!ac.check_ip("198.51.100.0".parse().unwrap()));
252+
assert!(!ac.check_ip("198.51.100.255".parse().unwrap()));
253+
assert!(ac.check_ip("198.51.101.0".parse().unwrap()));
254+
}
255+
256+
#[test]
257+
fn empty_blocked_ips_allows_all() {
258+
let ac =
259+
IpFilter { geo_reader: None, blocked_regions: HashSet::new(), blocked_ips: vec![] };
260+
assert!(ac.check_ip("192.0.2.1".parse().unwrap()));
261+
assert!(ac.check_ip("2001:db8::1".parse().unwrap()));
262+
}
263+
206264
#[test]
207265
fn year_month_conversion_handles_leap_day() {
208266
// 2024-02-29 00:00:00 UTC

payjoin-mailroom/src/config.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ pub struct AcmeConfig {
5959
pub struct AccessControlConfig {
6060
pub geo_db_path: Option<PathBuf>,
6161
pub blocked_regions: Vec<String>,
62+
pub blocked_ips: Vec<String>,
6263
}
6364

6465
#[cfg(feature = "acme")]
@@ -139,6 +140,7 @@ impl Config {
139140
.with_list_parse_key("acme.domains")
140141
.with_list_parse_key("acme.contact")
141142
.with_list_parse_key("access_control.blocked_regions")
143+
.with_list_parse_key("access_control.blocked_ips")
142144
.try_parsing(true),
143145
)
144146
.build()?

payjoin-mailroom/src/lib.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ struct Services {
3030
relay: ohttp_relay::Service,
3131
metrics: MetricsService,
3232
#[cfg(feature = "access-control")]
33-
geoip: Option<std::sync::Arc<access_control::GeoIp>>,
33+
geoip: Option<std::sync::Arc<access_control::IpFilter>>,
3434
}
3535

3636
pub async fn serve(config: Config, meter_provider: Option<SdkMeterProvider>) -> anyhow::Result<()> {
@@ -231,10 +231,10 @@ async fn init_directory(
231231
#[cfg(feature = "access-control")]
232232
async fn init_geoip(
233233
config: &Config,
234-
) -> anyhow::Result<Option<std::sync::Arc<access_control::GeoIp>>> {
234+
) -> anyhow::Result<Option<std::sync::Arc<access_control::IpFilter>>> {
235235
match &config.access_control {
236236
Some(ac_config) => {
237-
let gi = access_control::GeoIp::from_config(ac_config, &config.storage_dir).await?;
237+
let gi = access_control::IpFilter::from_config(ac_config, &config.storage_dir).await?;
238238
info!("GeoIP access control enabled");
239239
Ok(Some(std::sync::Arc::new(gi)))
240240
}

payjoin-mailroom/src/middleware.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ pub struct MaybePeerIp(pub Option<std::net::IpAddr>);
1212
pub async fn check_geoip(req: Request, next: Next) -> Response {
1313
use axum::http::StatusCode;
1414

15-
let geoip = req.extensions().get::<Option<std::sync::Arc<crate::access_control::GeoIp>>>();
15+
let geoip = req.extensions().get::<Option<std::sync::Arc<crate::access_control::IpFilter>>>();
1616

1717
if let Some(Some(geoip)) = geoip {
1818
if let Some(connect_info) =

0 commit comments

Comments
 (0)