jdx/mise · error

GitHub relay permits read-only repository operations only

Error message

GitHub relay permits read-only repository operations only

What it means

The relay allowlists only read-only operations per path: git smart-HTTP upload-pack handshake and POST, GET/HEAD on repo metadata, refs, contents, releases, tags, branches, tarballs/zipballs, and web release/archive downloads. Any request whose method or path shape falls outside these patterns is rejected because the relay must never forward credentials to mutating or arbitrary GitHub operations.

Solutions

  1. Restrict the client to read-only operations (clone/fetch, GET/HEAD API reads, release and archive downloads)
  2. Perform write operations (push, API mutations) directly against github.com with your own credentials instead of via the relay
  3. Check the endpoint kind is one of: info/refs, git-upload-pack, repo metadata, git/refs|matching-refs, contents, releases, tags, branches, tarball, zipball, releases/download, archive
  4. Use GET or HEAD (except the git-upload-pack POST) — switch any mutating method

Example fix

// before
POST https://relay/git/org/repo/info/refs?service=git-receive-pack
// after
git push https://github.com/org/repo.git  # writes bypass the read-only relay
Defensive patterns

Strategy: validation

Validate before calling

const READ_ONLY: [(&str, [&str; 2]); 2] = [("GET", []), ("HEAD", [])];
fn is_read_only(method: &str, is_git_upload_pack_post: bool) -> bool {
    method == "GET" || method == "HEAD" || is_git_upload_pack_post
}
assert!(is_read_only("GET", false));

Try / catch

match authorize(&scope, method, path, query) {
    Err(e) if e.to_string().contains("read-only repository operations") => {
        eprintln!("{method} {path} is not permitted; perform writes directly on github.com");
    }
    Ok(t) => forward(t),
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: A `POST` to `git/OWNER/REPO/info/refs` (only GET is allowed); a git push attempt (`POST git-upload-pack` is allowed, but `git-receive-pack` paths are not in the allowlist, so pushes fail); `DELETE`/`PATCH`/`PUT` on `api/repos/...`; `POST` to any `web/` download path; any unrecognized path under an allowed prefix (e.g. `api/repos/o/r/commits`).

Common situations: Trying to `git push` through the relay (receive-pack is not permitted); a tool issuing write API calls (create release, edit file) through the proxy; a client using an endpoint kind not in the allowlist like `commits`, `pulls`, or `issues`.

Related errors


AI-assisted analysis of jdx/mise@533346cc37 (2026-09-17). Data as JSON: /api/errors/332a4b41aa6b3a55. Report an issue: GitHub.

Appendix: source

Thrown at src/github_relay.rs:229

            method == "GET" && query == Some("service=git-upload-pack")
        }
        ["git", _, _, "git-upload-pack"] => method == "POST" && query.is_none(),
        ["api", "repos", _, _] => method == "GET" || method == "HEAD",
        ["api", "repos", _, _, "git", kind, ..] => {
            matches!(*kind, "refs" | "matching-refs") && matches!(method, "GET" | "HEAD")
        }
        ["api", "repos", _, _, kind, ..] => {
            matches!(
                *kind,
                "contents" | "releases" | "tags" | "branches" | "tarball" | "zipball"
            ) && matches!(method, "GET" | "HEAD")
        }
        ["web", _, _, "releases", "download", _, ..] => matches!(method, "GET" | "HEAD"),
        ["web", _, _, "archive", _, ..] => matches!(method, "GET" | "HEAD"),
        _ => false,
    };
    if !allowed {
        bail!("GitHub relay permits read-only repository operations only");
    }
    if !git && let Some(query) = query {
        for (key, _) in url::form_urlencoded::parse(query.as_bytes()) {
            if !matches!(key.as_ref(), "ref" | "page" | "per_page") {
                bail!("unsupported query parameter");
            }
        }
    }
    let host = if p[0] == "api" {
        "api.github.com"
    } else {
        "github.com"
    };
    let suffix = path.split_once('/').expect("validated path").1;
    let mut url = format!("https://{host}/{suffix}");
    let archive_repo = match p.as_slice() {
        ["api", "repos", _, _, "tarball" | "zipball", ..] => Some(name.clone()),
        ["web", _, _, "archive", rest @ ..] => {

View on GitHub (pinned to 533346cc37)