Skip to main content

link_assistant_router/
github_proxy.rs

1//! GitHub API credential proxy with a deny-by-default destructive policy.
2
3use 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    /// Load the opt-in proxy from environment configuration.
37    ///
38    /// `GITHUB_PROXY_TOKEN` contains the operator credential,
39    /// `GITHUB_PROXY_BASE_URL` overrides GitHub for tests/enterprise, and
40    /// `GITHUB_PROXY_POLICY` points at an ordered JSON rule file.
41    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    /// First matching configured rule wins; built-in destructive denials are
99    /// evaluated afterwards.
100    #[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    /// `*` matches inside one path segment; `**` matches the remainder.
161    pub path: String,
162    /// Optional case-insensitive substring required in a GraphQL body.
163    #[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
232/// GraphQL names outside comments and quoted values. Destructive operations
233/// may follow fragments or another named operation, so checking only the
234/// document prefix is unsafe.
235fn 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}