{"record":{"id":"c5fdcbb3bfb7150b","repo":"jdx/mise","slug":"invalid-relay-path-encoding","errorCode":null,"errorMessage":"invalid relay path encoding","messagePattern":"invalid relay path encoding","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/github_relay.rs","lineNumber":180,"sourceCode":"struct Target {\n    url: String,\n    git: bool,\n    archive_repo: Option<String>,\n}\n\n#[cfg(any(unix, test))]\nfn validate_path(path: &str) -> Result<()> {\n    // Validate decoded segments, retaining the original spelling upstream. Reject\n    // encoded separators and percent signs so a second decoder cannot change scope.\n    for segment in path.split('/') {\n        for (index, byte) in segment.bytes().enumerate() {\n            if byte == b'%'\n                && !segment\n                    .as_bytes()\n                    .get(index + 1..index + 3)\n                    .is_some_and(|digits| digits.iter().all(u8::is_ascii_hexdigit))\n            {\n                bail!(\"invalid relay path encoding\");\n            }\n        }\n        let decoded = urlencoding::decode(segment)?;\n        if decoded.is_empty()\n            || matches!(decoded.as_ref(), \".\" | \"..\")\n            || decoded.contains(['/', '\\\\', '%'])\n            || decoded.chars().any(char::is_control)\n        {\n            bail!(\"invalid relay path\");\n        }\n    }\n    Ok(())\n}\n\n#[cfg(any(unix, test))]\nfn authorize(scope: &Scope, method: &str, path: &str, query: Option<&str>) -> Result<Target> {\n    validate_path(path)?;\n    let p: Vec<_> = path.split('/').collect();","sourceCodeStart":162,"sourceCodeEnd":198,"githubUrl":"https://github.com/jdx/mise/blob/533346cc374382b41ec5ff70536252b2e96e725c/src/github_relay.rs#L162-L198","documentation":"`validate_path` rejects relay request paths containing a `%` that is not followed by two hex digits (or the `%` sits at the very end of a segment). Because the relay later percent-decodes segments, a malformed escape would either fail decoding or allow a second decoder downstream to reinterpret the path, so it is rejected up front as a security guard against scope bypass.","triggerScenarios":"Any relayed request path segment containing a bare `%` not forming a valid `%XX` escape: e.g. `/git/org/repo/info/refs` with a literal `%` in a ref name, a truncated escape like `%A`, `%ZZ`, or a `%` at the end of a segment such as `/api/repos/org/re%`.","commonSituations":"A tool or script passes a raw filename containing `%` (common in generated URLs, logs, or Windows-encoded names) into an archive or contents path; a truncated double-encoding (`%25` became `%`) after an intermediate proxy rewrote the URL.","solutions":["Percent-encode the path properly so every `%` becomes `%25` and real escapes use two hex digits","Remove or escape the stray `%` character from the requested path/ref/filename","If a proxy rewrote the URL, disable the rewriting or re-encode the path before it reaches the relay"],"exampleFix":"// before\nlet path = \"/web/org/repo/archive/refs/tags/v1.0%.tar.gz\";\n// after\nlet path = \"/web/org/repo/archive/refs/tags/v1.0%25.tar.gz\";","handlingStrategy":"validation","validationCode":"fn has_valid_encoding(path: &str) -> bool {\n    path.split('/').all(|seg| {\n        seg.bytes().enumerate().all(|(i, b)| {\n            b != b'%' || seg.as_bytes().get(i+1..i+3)\n                .is_some_and(|d| d.iter().all(u8::is_ascii_hexdigit))\n        })\n    })\n}","typeGuard":null,"tryCatchPattern":"match authorize(&scope, method, path, query) {\n    Err(e) if e.to_string().contains(\"invalid relay path encoding\") => {\n        eprintln!(\"path contains a malformed % escape: {path}\");\n    }\n    Ok(t) => forward(t),\n    Err(e) => return Err(e),\n}","preventionTips":["Use a URL-encoding library rather than hand-building paths","Never splice raw user/filename strings containing % into URLs","Avoid double-encoding; encode once at URL construction time","Beware proxies that rewrite or truncate percent-escapes"],"tags":["github","url-encoding","security","relay"],"backgroundTag":"invalid-url-format","analyzedSha":"533346cc374382b41ec5ff70536252b2e96e725c","analyzedAt":"2026-09-17T13:35:38.149Z","contentChangedAt":"2026-09-17T13:35:38.149Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}