{"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/afd2eddd3a50c16190efc1c7e94404b48f72af57/src/github_relay.rs#L162-L198","documentation":"`validate_path` percent-decodes each segment of the relayed URL path. Before decoding it checks that every `%` is followed by exactly two ASCII hex digits; a bare or malformed `%` (e.g. `100%`, `%ZZ`) makes the segment undecodable and fails with this error, preventing ambiguous or smuggling-style paths from reaching the upstream host.","triggerScenarios":"Calling `authorize` or `archive_redirect` with a path segment containing `%` not forming a valid percent-escape — e.g. `/api/repos/owner/repo/tarball/100%done` or `/git/owner/repo.git/info%2refs`.","commonSituations":"Refs, tags, or file names containing literal `%` that were not percent-encoded (`v1.0%`); double-encoded values pasted from logs (`%2520`); building the path with an unencoded user-supplied string.","solutions":["Percent-encode the segment properly with `urlencoding::encode` (a literal `%` becomes `%25`).","Remove or rename the ref/tag containing the stray `%` character.","Verify the URL wasn't double-encoded; decode once yourself before passing the path."],"exampleFix":"// before\nlet path = format!(\"/api/repos/{owner}/{repo}/tarball/{tag}\"); // tag = \"v1.0%\"\n// after\nlet path = format!(\"/api/repos/{owner}/{repo}/tarball/{}\", urlencoding::encode(tag));","handlingStrategy":"validation","validationCode":"fn segment_encodes_cleanly(seg: &str) -> bool {\n    let b = seg.as_bytes();\n    (0..b.len()).all(|i| b[i] != b'%' || (i + 2 < b.len()\n        && b[i+1].is_ascii_hexdigit() && b[i+2].is_ascii_hexdigit()))\n}\nlet encoded = urlencoding::encode(raw_segment); // encode % as %25 before building the path","typeGuard":null,"tryCatchPattern":"match relay::authorize(&scope, \"GET\", path, None) {\n    Err(e) if e.to_string().contains(\"path encoding\") => eprintln!(\"percent-encode segment: {e}\"),\n    Err(e) => return Err(e),\n    Ok(t) => t,\n}","preventionTips":["Always percent-encode dynamic path segments with `urlencoding::encode` before composing URLs.","Never paste percent-encoded strings from logs (double encoding); encode exactly once.","Reject refs/tags containing `%` at input time rather than at request time."],"tags":["url","encoding","validation","security"],"backgroundTag":"invalid-url-format","analyzedSha":"afd2eddd3a50c16190efc1c7e94404b48f72af57","analyzedAt":"2026-09-09T01:38:25.179Z","contentChangedAt":"2026-09-09T01:38:25.179Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}