1use std::path::Path;
4
5use axum::body::Body;
6use axum::extract::{Request, State};
7use axum::http::{HeaderValue, StatusCode};
8use axum::response::Response;
9use serde::Deserialize;
10use serde_json::Value;
11#[cfg(test)]
12use serde_json::json;
13
14use crate::app_state::AppState;
15
16const POLICY_HEADER: &str = "x-link-assistant-policy";
17
18#[derive(Clone, Debug)]
19pub struct GitHubProxyConfig {
20 token: Option<String>,
21 pub base_url: String,
22 pub policy: GitHubPolicy,
23}
24
25impl Default for GitHubProxyConfig {
26 fn default() -> Self {
27 Self {
28 token: None,
29 base_url: "https://api.github.com".into(),
30 policy: GitHubPolicy::default(),
31 }
32 }
33}
34
35impl GitHubProxyConfig {
36 pub fn from_env() -> Result<Self, String> {
42 let mut token = std::env::var("GITHUB_PROXY_TOKEN")
43 .ok()
44 .filter(|token| !token.is_empty());
45 if token.is_none()
46 && let Ok(path) = std::env::var("GITHUB_PROXY_TOKEN_FILE")
47 && !path.is_empty()
48 {
49 token = Some(
50 std::fs::read_to_string(&path)
51 .map_err(|error| format!("could not read GitHub credential {path}: {error}"))?
52 .trim()
53 .to_string(),
54 )
55 .filter(|token| !token.is_empty());
56 }
57 token = token.or_else(|| {
58 std::env::var("GITHUB_PROXY_TOKEN_ENV")
59 .ok()
60 .and_then(|name| std::env::var(name).ok())
61 .filter(|token| !token.is_empty())
62 });
63 let base_url = std::env::var("GITHUB_PROXY_BASE_URL")
64 .unwrap_or_else(|_| "https://api.github.com".into())
65 .trim_end_matches('/')
66 .to_string();
67 let policy = std::env::var("GITHUB_PROXY_POLICY")
68 .ok()
69 .filter(|path| !path.is_empty())
70 .map(|path| GitHubPolicy::from_path(Path::new(&path)))
71 .transpose()?
72 .unwrap_or_default();
73 Ok(Self {
74 token,
75 base_url,
76 policy,
77 })
78 }
79
80 #[must_use]
81 pub const fn enabled(&self) -> bool {
82 self.token.is_some()
83 }
84
85 #[cfg(test)]
86 fn with_token(token: &str, base_url: &str) -> Self {
87 Self {
88 token: Some(token.into()),
89 base_url: base_url.trim_end_matches('/').into(),
90 policy: GitHubPolicy::default(),
91 }
92 }
93}
94
95#[derive(Clone, Debug, Default, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct GitHubPolicy {
98 #[serde(default)]
101 pub rules: Vec<PolicyRule>,
102}
103
104impl GitHubPolicy {
105 fn from_path(path: &Path) -> Result<Self, String> {
106 let bytes = std::fs::read(path)
107 .map_err(|error| format!("could not read GitHub policy {}: {error}", path.display()))?;
108 serde_json::from_slice(&bytes)
109 .map_err(|error| format!("invalid GitHub policy {}: {error}", path.display()))
110 }
111
112 #[must_use]
113 pub fn decision(&self, method: &str, path: &str, body: &[u8]) -> PolicyDecision {
114 for rule in &self.rules {
115 if rule.matches(method, path, body) {
116 return rule.effect.into();
117 }
118 }
119 if method.eq_ignore_ascii_case("DELETE") {
120 return PolicyDecision::Deny;
121 }
122 if method.eq_ignore_ascii_case("PATCH")
123 && path.contains("/git/refs/")
124 && serde_json::from_slice::<Value>(body)
125 .ok()
126 .and_then(|value| value.get("force").and_then(Value::as_bool))
127 == Some(true)
128 {
129 return PolicyDecision::Deny;
130 }
131 if path == "/graphql" && destructive_graphql(body) {
132 return PolicyDecision::Deny;
133 }
134 PolicyDecision::Allow
135 }
136}
137
138#[derive(Clone, Copy, Debug, Deserialize)]
139#[serde(rename_all = "lowercase")]
140pub enum PolicyEffect {
141 Allow,
142 Deny,
143}
144
145impl From<PolicyEffect> for PolicyDecision {
146 fn from(value: PolicyEffect) -> Self {
147 match value {
148 PolicyEffect::Allow => Self::Allow,
149 PolicyEffect::Deny => Self::Deny,
150 }
151 }
152}
153
154#[derive(Clone, Debug, Deserialize)]
155#[serde(deny_unknown_fields)]
156pub struct PolicyRule {
157 pub effect: PolicyEffect,
158 #[serde(default)]
159 pub method: Option<String>,
160 pub path: String,
162 #[serde(default)]
164 pub body_contains: Option<String>,
165}
166
167impl PolicyRule {
168 fn matches(&self, method: &str, path: &str, body: &[u8]) -> bool {
169 self.method
170 .as_deref()
171 .is_none_or(|expected| expected.eq_ignore_ascii_case(method))
172 && glob_matches(&self.path, path)
173 && self.body_contains.as_deref().is_none_or(|needle| {
174 String::from_utf8_lossy(body)
175 .to_ascii_lowercase()
176 .contains(&needle.to_ascii_lowercase())
177 })
178 }
179}
180
181#[derive(Clone, Copy, Debug, Eq, PartialEq)]
182pub enum PolicyDecision {
183 Allow,
184 Deny,
185}
186
187fn glob_matches(pattern: &str, value: &str) -> bool {
188 if let Some(prefix) = pattern.strip_suffix("/**") {
189 return value == prefix
190 || value
191 .strip_prefix(prefix)
192 .is_some_and(|rest| rest.starts_with('/'));
193 }
194 let pattern = pattern.split('/').collect::<Vec<_>>();
195 let value = value.split('/').collect::<Vec<_>>();
196 pattern.len() == value.len()
197 && pattern
198 .iter()
199 .zip(value)
200 .all(|(expected, actual)| *expected == "*" || *expected == actual)
201}
202
203fn destructive_graphql(body: &[u8]) -> bool {
204 let Ok(value) = serde_json::from_slice::<Value>(body) else {
205 return false;
206 };
207 let Some(query) = value.get("query").and_then(Value::as_str) else {
208 return false;
209 };
210 let compact = query
211 .chars()
212 .filter(|character| !character.is_whitespace())
213 .collect::<String>()
214 .to_ascii_lowercase();
215 let names = graphql_name_tokens(query);
216 if !names.iter().any(|name| name == "mutation") {
217 return false;
218 }
219 let has_delete = names.iter().any(|name| name.starts_with("delete"));
220 let updates_ref = names
221 .iter()
222 .any(|name| matches!(name.as_str(), "updateref" | "updaterefs"));
223 let forced_ref = updates_ref
224 && (compact.contains("force:true")
225 || value.get("variables").is_some_and(contains_forced_true));
226 let deletes_ref = names.iter().any(|name| name == "updaterefs")
227 && (contains_inline_zero_after_oid(&compact)
228 || value.get("variables").is_some_and(contains_zero_after_oid));
229 has_delete || forced_ref || deletes_ref
230}
231
232fn graphql_name_tokens(query: &str) -> Vec<String> {
236 let characters = query.chars().collect::<Vec<_>>();
237 let mut tokens = Vec::new();
238 let mut position = 0;
239 while position < characters.len() {
240 if characters[position] == '#' {
241 position += 1;
242 while position < characters.len() && characters[position] != '\n' {
243 position += 1;
244 }
245 continue;
246 }
247 if characters[position] == '"' {
248 let block = characters.get(position + 1) == Some(&'"')
249 && characters.get(position + 2) == Some(&'"');
250 position += if block { 3 } else { 1 };
251 while position < characters.len() {
252 if block
253 && characters.get(position) == Some(&'"')
254 && characters.get(position + 1) == Some(&'"')
255 && characters.get(position + 2) == Some(&'"')
256 {
257 position += 3;
258 break;
259 }
260 if !block && characters[position] == '"' {
261 position += 1;
262 break;
263 }
264 if !block && characters[position] == '\\' {
265 position += 1;
266 }
267 position += 1;
268 }
269 continue;
270 }
271 if characters[position].is_ascii_alphabetic() || characters[position] == '_' {
272 let start = position;
273 position += 1;
274 while position < characters.len()
275 && (characters[position].is_ascii_alphanumeric() || characters[position] == '_')
276 {
277 position += 1;
278 }
279 tokens.push(
280 characters[start..position]
281 .iter()
282 .collect::<String>()
283 .to_ascii_lowercase(),
284 );
285 continue;
286 }
287 position += 1;
288 }
289 tokens
290}
291
292fn contains_inline_zero_after_oid(compact_query: &str) -> bool {
293 let mut remainder = compact_query;
294 while let Some((_, after)) = remainder.split_once("afteroid:\"") {
295 let value = after.split('"').next().unwrap_or_default();
296 if value.len() >= 40 && value.bytes().all(|byte| byte == b'0') {
297 return true;
298 }
299 remainder = after;
300 }
301 false
302}
303
304fn contains_forced_true(value: &Value) -> bool {
305 match value {
306 Value::Object(fields) => fields.iter().any(|(name, value)| {
307 (name.eq_ignore_ascii_case("force") && value.as_bool() == Some(true))
308 || contains_forced_true(value)
309 }),
310 Value::Array(values) => values.iter().any(contains_forced_true),
311 _ => false,
312 }
313}
314
315fn contains_zero_after_oid(value: &Value) -> bool {
316 match value {
317 Value::Object(fields) => fields.iter().any(|(name, value)| {
318 (name.eq_ignore_ascii_case("afterOid")
319 && value
320 .as_str()
321 .is_some_and(|oid| oid.len() >= 40 && oid.bytes().all(|byte| byte == b'0')))
322 || contains_zero_after_oid(value)
323 }),
324 Value::Array(values) => values.iter().any(contains_zero_after_oid),
325 _ => false,
326 }
327}
328
329pub async fn proxy(State(state): State<AppState>, request: Request) -> Response {
330 forward(
331 &state.client,
332 &state.github,
333 state.max_proxy_request_bytes,
334 request,
335 )
336 .await
337}
338
339async fn forward(
340 client: &reqwest::Client,
341 github: &GitHubProxyConfig,
342 max_request_bytes: usize,
343 request: Request,
344) -> Response {
345 let Some(token) = github.token.as_deref() else {
346 return github_error(
347 StatusCode::SERVICE_UNAVAILABLE,
348 "GitHub proxy is not configured",
349 );
350 };
351 let (parts, body) = request.into_parts();
352 let body = match axum::body::to_bytes(body, max_request_bytes).await {
353 Ok(body) => body,
354 Err(error) => {
355 return github_error(
356 StatusCode::PAYLOAD_TOO_LARGE,
357 &format!("request body exceeds the proxy limit: {error}"),
358 );
359 }
360 };
361 let upstream_path = normalize_path(parts.uri.path());
362 if github
363 .policy
364 .decision(parts.method.as_str(), &upstream_path, &body)
365 == PolicyDecision::Deny
366 {
367 let mut response = github_error(
368 StatusCode::FORBIDDEN,
369 "Blocked by Link.Assistant.Router GitHub policy",
370 );
371 response
372 .headers_mut()
373 .insert(POLICY_HEADER, HeaderValue::from_static("blocked"));
374 return response;
375 }
376 let mut url = upstream_url(&github.base_url, &upstream_path);
377 if let Some(query) = parts.uri.query() {
378 url.push('?');
379 url.push_str(query);
380 }
381 let mut upstream = client.request(parts.method.clone(), url).bearer_auth(token);
382 for name in [
383 "accept",
384 "content-type",
385 "user-agent",
386 "time-zone",
387 "x-github-api-version",
388 "if-none-match",
389 "if-modified-since",
390 ] {
391 if let Some(value) = parts.headers.get(name) {
392 upstream = upstream.header(name, value);
393 }
394 }
395 let response = match upstream.body(body).send().await {
396 Ok(response) => response,
397 Err(error) => {
398 return github_error(
399 StatusCode::BAD_GATEWAY,
400 &format!("GitHub upstream request failed: {error}"),
401 );
402 }
403 };
404 let status = response.status();
405 let headers = crate::proxy::relay_response_headers(response.headers());
406 let bytes = match response.bytes().await {
407 Ok(bytes) => bytes,
408 Err(error) => {
409 return github_error(
410 StatusCode::BAD_GATEWAY,
411 &format!("GitHub upstream response failed: {error}"),
412 );
413 }
414 };
415 let mut result = Response::new(Body::from(bytes));
416 *result.status_mut() = status;
417 *result.headers_mut() = headers;
418 result
419}
420
421fn normalize_path(path: &str) -> String {
422 path.strip_prefix("/api/v3")
423 .or_else(|| path.strip_prefix("/github"))
424 .filter(|path| !path.is_empty())
425 .unwrap_or_else(|| {
426 if path == "/api/graphql" {
427 "/graphql"
428 } else {
429 path
430 }
431 })
432 .to_string()
433}
434
435fn upstream_url(base_url: &str, path: &str) -> String {
436 if path == "/graphql"
437 && let Some(root) = base_url.strip_suffix("/api/v3")
438 {
439 return format!("{root}/api/graphql");
440 }
441 format!("{base_url}{path}")
442}
443
444fn github_error(status: StatusCode, message: &str) -> Response {
445 let dialect = crate::api_error::dialect_for_path("/api/v3");
446 crate::api_error::PresentedError {
447 status,
448 error_type: "policy_error",
449 message,
450 }
451 .render(dialect)
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use axum::http::Request as HttpRequest;
458 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
459
460 #[test]
461 fn enterprise_and_bare_paths_normalize_to_github_rest() {
462 assert!(GitHubProxyConfig::with_token("operator", "https://example.test").enabled());
463 assert_eq!(normalize_path("/api/v3/rate_limit"), "/rate_limit");
464 assert_eq!(normalize_path("/repos/o/r"), "/repos/o/r");
465 assert_eq!(normalize_path("/api/graphql"), "/graphql");
466 assert_eq!(
467 upstream_url("https://github.example/api/v3", "/graphql"),
468 "https://github.example/api/graphql"
469 );
470 }
471
472 #[test]
473 fn default_policy_blocks_each_destructive_class() {
474 let policy = GitHubPolicy::default();
475 for path in [
476 "/repos/o/r",
477 "/repos/o/r/git/refs/heads/main",
478 "/repos/o/r/git/refs/tags/v1",
479 "/repos/o/r/releases/1",
480 "/repos/o/r/issues/1",
481 "/repos/o/r/issues/comments/1",
482 "/repos/o/r/actions/workflows/ci.yml",
483 "/orgs/o/packages/container/p/versions/1",
484 "/repos/o/r/deploy-keys/1",
485 "/repos/o/r/hooks/1",
486 ] {
487 assert_eq!(
488 policy.decision("DELETE", path, b""),
489 PolicyDecision::Deny,
490 "DELETE {path} must be blocked by default"
491 );
492 }
493 assert_eq!(
494 policy.decision(
495 "PATCH",
496 "/repos/o/r/git/refs/heads/main",
497 br#"{"force":true}"#
498 ),
499 PolicyDecision::Deny
500 );
501 assert_eq!(
502 policy.decision(
503 "POST",
504 "/graphql",
505 br#"{"query":"mutation { deleteIssue(input:{}) { clientMutationId } }"}"#
506 ),
507 PolicyDecision::Deny
508 );
509 assert_eq!(
510 policy.decision(
511 "PATCH",
512 "/repos/o/r/git/refs/heads/main",
513 br#"{"force":false}"#
514 ),
515 PolicyDecision::Allow
516 );
517 assert_eq!(
518 policy.decision(
519 "POST",
520 "/graphql",
521 br#"{"query":"mutation($input:UpdateRefInput!){updateRef(input:$input){clientMutationId}}","variables":{"input":{"force":true}}}"#
522 ),
523 PolicyDecision::Deny
524 );
525 assert_eq!(
526 policy.decision(
527 "POST",
528 "/graphql",
529 br##"{"query":"# a harmless preface\nfragment F on Repository { name }\nmutation Remove { deleteRelease(input:{releaseId:\"x\"}) { clientMutationId } }"}"##
530 ),
531 PolicyDecision::Deny
532 );
533 assert_eq!(
534 policy.decision(
535 "POST",
536 "/graphql",
537 br#"{"query":"mutation($updates:[RefUpdate!]!){updateRefs(input:{repositoryId:\"r\",refUpdates:$updates}){clientMutationId}}","variables":{"updates":[{"name":"refs/heads/main","afterOid":"0000000000000000000000000000000000000000"}]}}"#
538 ),
539 PolicyDecision::Deny
540 );
541 assert_eq!(
542 policy.decision(
543 "POST",
544 "/graphql",
545 br#"{"query":"mutation { updateRefs(input:{repositoryId:\"r\",refUpdates:[{name:\"refs/heads/main\",afterOid:\"0000000000000000000000000000000000000000\"}]}) { clientMutationId } }"}"#
546 ),
547 PolicyDecision::Deny
548 );
549 }
550
551 #[test]
552 fn policy_rejects_misspelled_configuration_fields() {
553 let error = serde_json::from_value::<GitHubPolicy>(json!({
554 "rules": [{"effect":"deny", "path":"/**", "methd":"POST"}]
555 }))
556 .unwrap_err();
557 assert!(error.to_string().contains("unknown field `methd`"));
558 }
559
560 #[test]
561 fn explicit_allow_overrides_one_default_without_weakening_others() {
562 let policy: GitHubPolicy = serde_json::from_value(json!({"rules": [{
563 "effect": "allow", "method": "DELETE", "path": "/repos/o/r/issues/*"
564 }]}))
565 .unwrap();
566 assert_eq!(
567 policy.decision("DELETE", "/repos/o/r/issues/1", b""),
568 PolicyDecision::Allow
569 );
570 assert_eq!(
571 policy.decision("DELETE", "/repos/o/r/releases/1", b""),
572 PolicyDecision::Deny
573 );
574 }
575
576 #[tokio::test]
577 async fn forwarding_contains_credentials_and_preserves_rate_limits() {
578 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
579 let address = listener.local_addr().unwrap();
580 let server = tokio::spawn(async move {
581 let (mut socket, _) = listener.accept().await.unwrap();
582 let mut request = vec![0_u8; 8 * 1024];
583 let read = socket.read(&mut request).await.unwrap();
584 socket
585 .write_all(
586 b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nx-ratelimit-remaining: 42\r\nset-cookie: upstream=secret\r\ncontent-length: 11\r\n\r\n{\"ok\":true}",
587 )
588 .await
589 .unwrap();
590 String::from_utf8_lossy(&request[..read]).to_string()
591 });
592 let config = GitHubProxyConfig::with_token("operator-secret", &format!("http://{address}"));
593 let request = HttpRequest::builder()
594 .uri("/api/v3/rate_limit")
595 .header("authorization", "Bearer caller-placeholder")
596 .body(Body::empty())
597 .unwrap();
598
599 let response = forward(
600 &reqwest::Client::new(),
601 &config,
602 crate::config::DEFAULT_MAX_PROXY_REQUEST_BYTES,
603 request,
604 )
605 .await;
606 let forwarded = server.await.unwrap().to_ascii_lowercase();
607 assert_eq!(response.status(), StatusCode::OK);
608 assert_eq!(response.headers()["x-ratelimit-remaining"], "42");
609 assert!(!response.headers().contains_key("set-cookie"));
610 assert!(forwarded.contains("authorization: bearer operator-secret"));
611 assert!(!forwarded.contains("caller-placeholder"));
612 }
613}