Skip to content

Commit caae58a

Browse files
feat: add configurable resource limits, fix rate limiter
Signed-off-by: Henry <mail@henrygressmann.de>
1 parent 580483a commit caae58a

19 files changed

Lines changed: 593 additions & 110 deletions

‎CHANGELOG.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Since this is not a library, this changelog focuses on the changes that are rele
2222

2323
### ⚠️ Breaking Changes
2424

25-
- Proxy-provided client IP headers are no longer trusted by default. Configure `client_ip_headers` to opt in
25+
- Proxy-provided client IP headers are no longer trusted by default. Configure `client_ip_headers` and `trusted_proxies` to opt in
2626

2727
### Features
2828

@@ -31,6 +31,13 @@ Since this is not a library, this changelog focuses on the changes that are rele
3131
- Tracker requests now avoid CORS preflight requests by sending JSON as `text/plain` and the event API accepts tracker JSON regardless of content type
3232
- Added external authentication with OpenID Connect, Google, and Microsoft Entra ID, including optional user creation and provider access restrictions
3333

34+
### Security
35+
36+
- Added input, result, concurrency, and execution-time limits to dashboard reports
37+
- Dashboard report limits can be adjusted in the `[limits]` configuration section
38+
- Rate limits now use configured client IP headers when requests come from a trusted reverse proxy
39+
- Onboarding tokens are now validated in constant time and consumed atomically when creating the initial administrator
40+
3441
### Other
3542

3643
- Renamed `trusted_headers` to `client_ip_headers` (the old name remains supported)

‎Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ tower = { version = "*", features = ["util"] }
106106
default = ["geoip"]
107107
geoip = ["dep:maxminddb"]
108108

109+
[lints.clippy]
110+
too_many_arguments = "allow"
111+
109112
[profile.dev]
110113
opt-level = 1
111114

‎benches/reports.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ fn benchmark_reports(c: &mut Criterion) {
3030
start: Utc::now().checked_sub_days(Days::new(365)).expect("failed to build range start"),
3131
end: Utc::now(),
3232
};
33-
let day_buckets = reports::build_graph_buckets(&range, GraphInterval::Day, Some("UTC"))
33+
let day_buckets = reports::build_graph_buckets(&range, GraphInterval::Day, Some("UTC"), 2000)
3434
.expect("failed to build day buckets for benchmark");
3535

3636
let conn = app.events_conn().expect("failed to get events connection");
@@ -80,7 +80,7 @@ fn benchmark_reports(c: &mut Criterion) {
8080
&metric,
8181
|b, metric| {
8282
b.iter(|| {
83-
reports::dimension_report(&conn, &entities, "pageview", &range, &dimension, &[], metric)
83+
reports::dimension_report(&conn, &entities, "pageview", &range, &dimension, &[], metric, 1000)
8484
.expect("dimension_report failed")
8585
});
8686
},

‎data/config.example.toml‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ listen = 9042 # The port to listen on (http)
1717
# client_ip_headers=["cloudflare", "fly", "X-Forwarded-For", "X-Client-IP"]
1818

1919
# # Trusted reverse proxies (IP or CIDR).
20-
# # Empty by default, meaning all proxies are trusted.
20+
# # Empty by default, meaning forwarded client IP and GeoIP headers are not trusted.
2121
# trusted_proxies=["127.0.0.1", "10.0.0.0/8"]
2222

2323
# # Hour of day, in the server's local timezone, when visitor grouping salts rotate.
@@ -42,7 +42,18 @@ listen = 9042 # The port to listen on (http)
4242
# # Otherwise, the database will be downloaded automatically and stored in the data_dir
4343
# maxmind_db_path="./GeoLite2-City.mmdb"
4444

45+
# Database settings
4546
[duckdb]
4647
# # See https://liwan.dev/guides/duckdb for guidance
4748
# threads=2
4849
# memory_limit=2G
50+
51+
# Resource limits for dashboard reports.
52+
[limits]
53+
# report_timeout_seconds=30
54+
# report_max_concurrency=8
55+
# report_max_range_days=3660
56+
# report_max_dimension_results=1000
57+
# report_max_datapoints=2000
58+
# report_max_filters=20
59+
# report_max_filter_value_bytes=2048

‎src/app/core/onboarding.rs‎

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
use anyhow::Result;
2-
use arc_swap::ArcSwapOption;
3-
use std::sync::Arc;
2+
use std::sync::{Arc, Mutex};
43

5-
use crate::{app::SqlitePool, utils::hash::onboarding_token};
4+
use crate::{
5+
app::SqlitePool,
6+
utils::hash::{onboarding_token, onboarding_token_matches},
7+
};
68

79
#[derive(Clone)]
810
pub struct LiwanOnboarding {
9-
token: Arc<ArcSwapOption<String>>,
11+
token: Arc<Mutex<Option<String>>>,
1012
}
1113

1214
impl LiwanOnboarding {
@@ -15,20 +17,69 @@ impl LiwanOnboarding {
1517
tracing::debug!("Checking if an onboarding token needs to be generated");
1618
let conn = pool.get()?;
1719
let onboarded = conn.prepare("select 1 from users limit 1")?.exists([])?;
18-
ArcSwapOption::new((!onboarded).then(|| onboarding_token().into()))
20+
(!onboarded).then(onboarding_token)
1921
};
2022

21-
Ok(Self { token: onboarding.into() })
23+
Ok(Self { token: Arc::new(Mutex::new(onboarding)) })
2224
}
2325

2426
/// Get the onboarding token, if it exists
25-
pub fn token(&self) -> Result<Option<String>> {
26-
Ok((self.token.load()).as_ref().map(|v| (**v).clone()))
27+
pub fn token(&self) -> Option<String> {
28+
self.token.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).clone()
2729
}
2830

2931
/// Clear the onboarding token to prevent it from being used again
30-
pub fn clear(&self) -> Result<()> {
31-
self.token.store(None);
32-
Ok(())
32+
pub fn clear(&self) {
33+
*self.token.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
34+
}
35+
36+
/// Complete setup once when the supplied token is valid.
37+
pub fn complete_setup(&self, supplied_token: &str, setup: impl FnOnce() -> Result<()>) -> Result<bool> {
38+
let mut token = self.token.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
39+
if !token.as_deref().is_some_and(|token| onboarding_token_matches(token, supplied_token)) {
40+
return Ok(false);
41+
}
42+
43+
setup()?;
44+
*token = None;
45+
Ok(true)
46+
}
47+
}
48+
49+
#[cfg(test)]
50+
mod tests {
51+
use super::*;
52+
use std::sync::atomic::{AtomicUsize, Ordering};
53+
54+
#[test]
55+
fn complete_allows_only_one_setup() {
56+
let onboarding = LiwanOnboarding { token: Arc::new(Mutex::new(Some("token".to_string()))) };
57+
let setup_count = Arc::new(AtomicUsize::new(0));
58+
let attempts = (0..2)
59+
.map(|_| {
60+
let onboarding = onboarding.clone();
61+
let setup_count = setup_count.clone();
62+
std::thread::spawn(move || {
63+
onboarding
64+
.complete_setup("token", || {
65+
setup_count.fetch_add(1, Ordering::Relaxed);
66+
Ok(())
67+
})
68+
.unwrap()
69+
})
70+
})
71+
.map(|attempt| attempt.join().unwrap())
72+
.collect::<Vec<_>>();
73+
74+
assert_eq!(attempts.iter().filter(|completed| **completed).count(), 1);
75+
assert_eq!(setup_count.load(Ordering::Relaxed), 1);
76+
}
77+
78+
#[test]
79+
fn complete_keeps_token_when_setup_fails() {
80+
let onboarding = LiwanOnboarding { token: Arc::new(Mutex::new(Some("token".to_string()))) };
81+
82+
assert!(onboarding.complete_setup("token", || anyhow::bail!("failed")).is_err());
83+
assert_eq!(onboarding.token().as_deref(), Some("token"));
3384
}
3485
}

‎src/app/core/reports/dimension.rs‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub fn dimension_report(
1717
dimension: &Dimension,
1818
filters: &[DimensionFilter],
1919
metric: &Metric,
20+
max_results: usize,
2021
) -> Result<ReportTable> {
2122
if entities.is_empty() {
2223
return Ok(BTreeMap::new());
@@ -87,7 +88,8 @@ pub fn dimension_report(
8788
{metric_column} as metric_value
8889
from session_data sd
8990
group by dimension_value
90-
order by metric_value desc;
91+
order by metric_value desc
92+
limit {max_results};
9193
"
9294
);
9395

@@ -112,3 +114,41 @@ pub fn dimension_report(
112114
}
113115
}
114116
}
117+
118+
#[cfg(test)]
119+
mod tests {
120+
use super::*;
121+
use crate::app::Liwan;
122+
use crate::config::Config;
123+
use chrono::{Duration, TimeZone, Utc};
124+
125+
#[test]
126+
fn dimension_report_limits_results() {
127+
let app = Liwan::new_memory(Config::default()).expect("failed to create app");
128+
let max_results = app.config.limits.report_max_dimension_results;
129+
let conn = app.events_conn().expect("failed to get events conn");
130+
conn.execute_batch(&format!(
131+
"insert into events (entity_id, visitor_group_id, event, created_at, fqdn, path)
132+
select 'entity-1', 'visitor-' || i, 'pageview', '2024-01-01'::timestamp, 'example.com', '/' || i
133+
from range({}) values(i)",
134+
max_results + 1
135+
))
136+
.expect("failed to insert events");
137+
let start = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
138+
let range = DateRange { start, end: start + Duration::days(1) };
139+
140+
let report = dimension_report(
141+
&conn,
142+
&["entity-1".to_string()],
143+
"pageview",
144+
&range,
145+
&Dimension::Path,
146+
&[],
147+
&Metric::Views,
148+
max_results,
149+
)
150+
.expect("failed to build dimension report");
151+
152+
assert_eq!(report.len(), max_results);
153+
}
154+
}

‎src/app/core/reports/graph.rs‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ pub fn build_graph_buckets(
5252
range: &DateRange,
5353
interval: GraphInterval,
5454
timezone: Option<&str>,
55+
max_datapoints: usize,
5556
) -> Result<Vec<DateRange>> {
5657
if range.start >= range.end {
5758
return Ok(Vec::new());
@@ -71,9 +72,31 @@ pub fn build_graph_buckets(
7172
GraphInterval::Day => resolve_local_day_start(timezone, range.start.with_timezone(&timezone).date_naive())?,
7273
};
7374

74-
let mut buckets = Vec::new();
75+
let mut bucket_count = 0;
7576
let mut bucket_start = aligned_start;
7677

78+
while bucket_start < range.end {
79+
let next_bucket_start = match interval {
80+
GraphInterval::Hour => bucket_start + Duration::hours(1),
81+
GraphInterval::Day => {
82+
let next_date = bucket_start
83+
.with_timezone(&timezone)
84+
.date_naive()
85+
.checked_add_days(Days::new(1))
86+
.context("Failed to advance bucket date")?;
87+
resolve_local_day_start(timezone, next_date)?
88+
}
89+
};
90+
91+
bucket_count += 1;
92+
if bucket_count > max_datapoints {
93+
anyhow::bail!("Too many data points");
94+
}
95+
bucket_start = next_bucket_start;
96+
}
97+
98+
let mut buckets = Vec::with_capacity(bucket_count);
99+
bucket_start = aligned_start;
77100
while bucket_start < range.end {
78101
let next_bucket_start = match interval {
79102
GraphInterval::Hour => bucket_start + Duration::hours(1),
@@ -248,7 +271,7 @@ mod tests {
248271
};
249272

250273
let buckets =
251-
build_graph_buckets(&range, GraphInterval::Hour, Some(timezone)).expect("failed to build buckets");
274+
build_graph_buckets(&range, GraphInterval::Hour, Some(timezone), 2000).expect("failed to build buckets");
252275
let local_starts = buckets
253276
.iter()
254277
.map(|bucket| bucket.start.with_timezone(&Tz::Asia__Kolkata).format("%Y-%m-%d %H:%M").to_string())
@@ -266,7 +289,8 @@ mod tests {
266289
end: local_datetime(Tz::America__New_York, 2024, 1, 3, 12, 0),
267290
};
268291

269-
let buckets = build_graph_buckets(&range, GraphInterval::Day, Some(timezone)).expect("failed to build buckets");
292+
let buckets =
293+
build_graph_buckets(&range, GraphInterval::Day, Some(timezone), 2000).expect("failed to build buckets");
270294
let local_starts = buckets
271295
.iter()
272296
.map(|bucket| bucket.start.with_timezone(&Tz::America__New_York).format("%Y-%m-%d %H:%M").to_string())
@@ -299,7 +323,8 @@ mod tests {
299323
.expect("failed to append events");
300324

301325
let range = DateRange { start, end };
302-
let buckets = build_graph_buckets(&range, GraphInterval::Day, Some("UTC")).expect("failed to build buckets");
326+
let buckets =
327+
build_graph_buckets(&range, GraphInterval::Day, Some("UTC"), 2000).expect("failed to build buckets");
303328
let conn = app.events_conn().expect("failed to get events conn");
304329
let report =
305330
overall_report(&conn, &["entity-1".to_string()], "pageview", &range, &buckets, &[], &Metric::Views)
@@ -308,4 +333,13 @@ mod tests {
308333
let values = report.iter().map(|point| point.value).collect::<Vec<_>>();
309334
assert_eq!(values, vec![1.0, 1.0]);
310335
}
336+
337+
#[test]
338+
fn build_graph_buckets_rejects_excessive_ranges() {
339+
let start = local_datetime(Tz::UTC, 2024, 1, 1, 0, 0);
340+
let max_datapoints = 2000;
341+
let range = DateRange { start, end: start + Duration::hours(max_datapoints as i64 + 1) };
342+
343+
assert!(build_graph_buckets(&range, GraphInterval::Hour, Some("UTC"), max_datapoints).is_err());
344+
}
311345
}

0 commit comments

Comments
 (0)