Skip to content

Commit 618d688

Browse files
committed
Rate limiting, caching, patching vulnerabilities
1 parent 91c4219 commit 618d688

5 files changed

Lines changed: 252 additions & 46 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ actix-web = "4.14.1"
88
anyhow = "1.0.104"
99
reqwest = { version = "0.13.4", features = ["json"] }
1010
serde = { version = "1.0.229", features = ["derive"] }
11+
serde_json = "1.0.151"
1112
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }

src/github.rs

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
use std::{
2+
collections::HashMap,
3+
sync::OnceLock,
4+
time::{Duration, Instant},
5+
};
6+
17
use anyhow::{Ok, Result};
2-
use reqwest::Response;
8+
use reqwest::Client;
39
use serde::{Deserialize, Serialize};
10+
use tokio::sync::Mutex;
411

5-
#[derive(Debug, Clone, Serialize, Deserialize)]
12+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
613
pub struct GithubPathEntry {
714
path: String,
815
mode: String,
@@ -11,7 +18,7 @@ pub struct GithubPathEntry {
1118
sha: String,
1219
}
1320

14-
#[derive(Debug, Clone, Serialize, Deserialize)]
21+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
1522
pub struct GithubRepoResponse {
1623
truncated: bool,
1724
sha: String,
@@ -20,24 +27,56 @@ pub struct GithubRepoResponse {
2027
}
2128

2229
fn github_token() -> Result<String> {
23-
std::env::var("GITHUB_TOKEN").map_err(|_| anyhow::anyhow!("GITHUB_TOKEN not set"))
30+
std::env::var("GITHUB_TOKEN").map_err(|_| anyhow::anyhow!("github credentials unavailable"))
2431
}
2532

26-
pub async fn send_github_request(url: &str) -> Result<Response> {
27-
let client = reqwest::Client::builder()
33+
static CLIENT: OnceLock<Client> = OnceLock::new();
34+
static CACHE: OnceLock<Mutex<HashMap<String, (String, Instant)>>> = OnceLock::new();
35+
36+
const REQUEST_TIMEOUT_SECONDS: u64 = 30;
37+
const CACHE_SECONDS: u64 = 300;
38+
39+
fn github_client() -> Result<&'static Client> {
40+
if let Some(client) = CLIENT.get() {
41+
return Ok(client);
42+
}
43+
44+
let client = Client::builder()
2845
.user_agent("HackClubPyterm (brendan@hackclub.com)")
46+
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECONDS))
2947
.build()?;
30-
client
48+
49+
Ok(CLIENT.get_or_init(|| client))
50+
}
51+
52+
pub async fn send_github_request(url: &str) -> Result<String> {
53+
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
54+
55+
if let Some((data, stored_at)) = cache.lock().await.get(url) {
56+
if Instant::now().duration_since(*stored_at).as_secs() < CACHE_SECONDS {
57+
return Ok(data.clone());
58+
}
59+
}
60+
61+
let output = github_client()?
3162
.get(url)
3263
.bearer_auth(github_token()?)
3364
.send()
34-
.await
35-
.map_err(|e| anyhow::anyhow!(e))
65+
.await?
66+
.error_for_status()?
67+
.text()
68+
.await?;
69+
70+
let mut handle = cache.lock().await;
71+
handle.retain(|_, x| Instant::now().duration_since(x.1).as_secs() < CACHE_SECONDS);
72+
handle.insert(url.to_string(), (output.clone(), Instant::now()));
73+
74+
Ok(output)
3675
}
3776

3877
pub async fn get_repo_files(user: &String, repo: &String) -> Result<Vec<String>> {
3978
let url = format!("https://api.github.com/repos/{user}/{repo}/git/trees/HEAD?recursive=1");
40-
let response: GithubRepoResponse = send_github_request(&url).await?.json().await?;
79+
let response: GithubRepoResponse = serde_json::from_str(&send_github_request(&url).await?)?;
4180

4281
Ok(response.tree.iter().map(|x| x.path.clone()).collect())
4382
}
@@ -47,9 +86,7 @@ pub async fn get_file_in_repo(user: &String, repo: &String, file_path: &String)
4786
"https://raw.githubusercontent.com/{user}/{repo}/HEAD/{}",
4887
file_path.trim_start_matches("/")
4988
);
50-
let response = send_github_request(&url).await?;
51-
52-
Ok(response.text().await?)
89+
Ok(send_github_request(&url).await?)
5390
}
5491

5592
pub async fn get_python_code(user: &String, repo: &String) -> Result<String> {
@@ -66,11 +103,18 @@ pub async fn get_python_code(user: &String, repo: &String) -> Result<String> {
66103
for query in files_as_priority {
67104
let query_string = query.to_string();
68105
if files.contains(&query_string) {
69-
path = Some(query_string)
106+
path = Some(query_string);
107+
break;
70108
}
71109
}
110+
72111
if path.is_none() {
73-
path = files.iter().find(|x| x.contains(".py")).cloned();
112+
path = files.iter().find(|x| x.ends_with(".py")).cloned();
74113
}
114+
115+
if path.is_none() {
116+
return Ok("No Python files in directory".to_string());
117+
}
118+
75119
get_file_in_repo(user, repo, &path.unwrap()).await
76120
}

src/main.rs

Lines changed: 167 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
pub mod github;
22

3+
use std::{
4+
sync::{Mutex, MutexGuard, OnceLock},
5+
time::Instant,
6+
};
7+
38
use actix_web::{
49
App, HttpRequest, HttpResponse, HttpServer, Responder, http::header, middleware::DefaultHeaders,
510
};
@@ -8,7 +13,135 @@ use crate::github::get_python_code;
813

914
const PAGE_HTML: &str = include_str!("ui/page.html");
1015

16+
static CONNECTIONS: OnceLock<Mutex<Vec<(String, Instant)>>> = OnceLock::new();
17+
static RATE_LIMITED: OnceLock<Mutex<Vec<(String, Instant)>>> = OnceLock::new();
18+
static RATE_LIMITED_THIS_INSTANCE: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
19+
static PEER_CONNECTIONS: OnceLock<Mutex<Vec<(String, Instant)>>> = OnceLock::new();
20+
21+
const MAX_PER_TEN_SECONDS: u32 = 70; // ten full page loads
22+
const MAX_PER_TEN_SECONDS_PEER: u32 = 700; // one hundred full page loads
23+
const RATE_LIMIT_MINUTES_FIRST: u64 = 1;
24+
const RATE_LIMIT_MINUTES_SECOND: u64 = 30;
25+
26+
fn escape_html(input: &str) -> String {
27+
input
28+
.replace("&", "&amp;")
29+
.replace("<", "&lt;")
30+
.replace(">", "&gt;")
31+
.replace("\"", "&quot;")
32+
.replace("'", "&#39;")
33+
}
34+
35+
fn extract_global<'a, T>(input: &'a OnceLock<Mutex<Vec<T>>>) -> MutexGuard<'a, Vec<T>> {
36+
input
37+
.get_or_init(|| Mutex::new(vec![]))
38+
.lock()
39+
.unwrap_or_else(|x| x.into_inner())
40+
}
41+
1142
async fn dispatch(req: HttpRequest) -> impl Responder {
43+
{
44+
let ip = req
45+
.connection_info()
46+
.realip_remote_addr()
47+
.map(|x| x.to_string())
48+
.unwrap_or(String::new());
49+
50+
let peer_ip = req
51+
.peer_addr()
52+
.map(|x| x.ip().to_string())
53+
.unwrap_or(String::new());
54+
if ip != peer_ip {
55+
eprintln!("forwarded ip {ip} claimed by peer {peer_ip}");
56+
}
57+
58+
let mut peer_connections = extract_global(&PEER_CONNECTIONS);
59+
*peer_connections = peer_connections
60+
.iter()
61+
.filter(|x| std::time::Instant::now().duration_since(x.1).as_secs() < 10)
62+
.map(|x| (x.0.clone(), x.1))
63+
.collect::<Vec<_>>();
64+
65+
let peer_count = peer_connections.iter().filter(|x| x.0 == peer_ip).count() as u32;
66+
if peer_count >= MAX_PER_TEN_SECONDS_PEER {
67+
return HttpResponse::TooManyRequests()
68+
.content_type("text/plain; charset=utf-8")
69+
.body("rate limited, please wait");
70+
}
71+
72+
peer_connections.push((peer_ip, std::time::Instant::now()));
73+
74+
let mut rate_limited = extract_global(&RATE_LIMITED);
75+
76+
let mut connections = extract_global(&CONNECTIONS);
77+
78+
let mut rate_limited_this_instance = extract_global(&RATE_LIMITED_THIS_INSTANCE);
79+
rate_limited_this_instance.sort();
80+
rate_limited_this_instance.dedup();
81+
82+
fn check_rate_limit_not_ready_to_clear(
83+
x: &(String, Instant),
84+
rate_limited_this_instance: &[String],
85+
) -> bool {
86+
let rate_limit_time = if !rate_limited_this_instance.contains(&x.0) {
87+
RATE_LIMIT_MINUTES_FIRST * 60
88+
} else {
89+
RATE_LIMIT_MINUTES_SECOND * 60
90+
};
91+
92+
std::time::Instant::now().duration_since(x.1).as_secs() < rate_limit_time
93+
}
94+
95+
let rate_limited_this_instance_immutable = rate_limited_this_instance.clone();
96+
97+
let expired = rate_limited
98+
.iter()
99+
.filter(|x| {
100+
!check_rate_limit_not_ready_to_clear(x, &rate_limited_this_instance_immutable)
101+
})
102+
.map(|x| x.0.clone())
103+
.collect::<Vec<_>>();
104+
105+
*rate_limited = rate_limited
106+
.iter()
107+
.filter(|x| {
108+
check_rate_limit_not_ready_to_clear(x, &rate_limited_this_instance_immutable)
109+
})
110+
.map(|x| (x.0.clone(), x.1))
111+
.collect::<Vec<_>>();
112+
113+
rate_limited_this_instance.extend(expired);
114+
115+
if rate_limited.iter().find(|x| x.0 == ip).is_some() {
116+
return HttpResponse::TooManyRequests()
117+
.content_type("text/plain; charset=utf-8")
118+
.body("rate limited, please wait");
119+
}
120+
121+
*connections = connections
122+
.iter()
123+
.filter(|x| std::time::Instant::now().duration_since(x.1).as_secs() < 10)
124+
.map(|x| (x.0.clone(), x.1))
125+
.collect::<Vec<_>>();
126+
connections.sort();
127+
let mut count = 1;
128+
let mut last = String::new();
129+
for (ip, _) in connections.iter() {
130+
if *ip == last {
131+
count += 1;
132+
} else {
133+
count = 1;
134+
}
135+
if count >= MAX_PER_TEN_SECONDS {
136+
rate_limited.push((ip.clone(), std::time::Instant::now()));
137+
}
138+
139+
last = ip.clone();
140+
}
141+
142+
connections.push((ip, std::time::Instant::now()));
143+
}
144+
12145
let path = req.path();
13146
let terms = path
14147
.split("/")
@@ -17,36 +150,56 @@ async fn dispatch(req: HttpRequest) -> impl Responder {
17150

18151
if terms.len() < 2 {
19152
eprintln!("Invalid path: {}", path);
20-
return HttpResponse::Ok().body("Invalid path");
153+
return HttpResponse::Ok()
154+
.content_type("text/plain; charset=utf-8")
155+
.body("Invalid path");
21156
}
22157

23158
let user = terms[0].to_string();
24159
let repo = terms[1].to_string();
25160
if terms.len() != 0 {
26161
match *terms.last().unwrap() {
27-
"term_style.css" => return HttpResponse::Ok().body(include_str!("ui/term_style.css")),
28-
"term_config.js" => return HttpResponse::Ok().body(include_str!("ui/term_config.js")),
29-
"conf.json" => return HttpResponse::Ok().body("{}"),
162+
"term_style.css" => {
163+
return HttpResponse::Ok()
164+
.content_type("text/css; charset=utf-8")
165+
.body(include_str!("ui/term_style.css"));
166+
}
167+
"term_config.js" => {
168+
return HttpResponse::Ok()
169+
.content_type("text/javascript; charset=utf-8")
170+
.body(include_str!("ui/term_config.js"));
171+
}
172+
"conf.json" => {
173+
return HttpResponse::Ok()
174+
.content_type("application/json")
175+
.body("{}");
176+
}
30177
"script.py" => {
31178
let python_code = match get_python_code(&user, &repo).await {
32179
Ok(code) => code,
33180
Err(e) => {
34181
eprintln!("Error: {}", e);
35-
return HttpResponse::Ok().body(format!("Error: {}", e));
182+
return HttpResponse::InternalServerError()
183+
.content_type("text/plain; charset=utf-8")
184+
.body("Internal server error");
36185
}
37186
};
38-
return HttpResponse::Ok().body(python_code);
187+
return HttpResponse::Ok()
188+
.content_type("text/plain; charset=utf-8")
189+
.body(python_code);
39190
}
40191
_ => {}
41192
}
42193
}
43194

44-
HttpResponse::Ok().body(
45-
PAGE_HTML
46-
.replace("{PAGE_TITLE}", &repo.to_string())
47-
.replace("{USER}", &user)
48-
.replace("{REPO}", &repo),
49-
)
195+
HttpResponse::Ok()
196+
.content_type("text/html; charset=utf-8")
197+
.body(
198+
PAGE_HTML
199+
.replace("{PAGE_TITLE}", &escape_html(&repo))
200+
.replace("{USER}", &escape_html(&user))
201+
.replace("{REPO}", &escape_html(&repo)),
202+
)
50203
}
51204

52205
#[actix_web::main]
@@ -57,7 +210,8 @@ async fn main() -> std::io::Result<()> {
57210
DefaultHeaders::new()
58211
.add((header::CROSS_ORIGIN_OPENER_POLICY, "same-origin"))
59212
.add((header::CROSS_ORIGIN_EMBEDDER_POLICY, "require-corp"))
60-
.add((header::CROSS_ORIGIN_RESOURCE_POLICY, "cross-origin")),
213+
.add((header::CROSS_ORIGIN_RESOURCE_POLICY, "cross-origin"))
214+
.add((header::X_CONTENT_TYPE_OPTIONS, "nosniff")),
61215
)
62216
.default_service(actix_web::web::to(dispatch))
63217
})

0 commit comments

Comments
 (0)