{"record":{"id":"71101d88666da324","repo":"JuliusBrussee/caveman","slug":"githubapp-request-path-must-be-a-single-host-abso","errorCode":null,"errorMessage":"githubapp: request path must be a single-host absolute path","messagePattern":"githubapp: request path must be a single-host absolute path","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"shared/platform/githubapp/githubapp.go","lineNumber":350,"sourceCode":"\t\treturn nil, fmt.Errorf(\"githubapp: decode repository proof content: %w\", err)\n\t}\n\tif len(decoded) > 64<<10 || int64(len(decoded)) != payload.Size {\n\t\treturn nil, fmt.Errorf(\"githubapp: repository proof size mismatch\")\n\t}\n\treturn decoded, nil\n}\n\n// DoToken issues an authenticated GitHub REST call with an installation token and\n// returns the status + raw body for the caller to parse. It is the reusable\n// primitive the worker's PR opener builds the Git Data API flow on, so every\n// GitHub egress goes through the one SSRF-guarded client + fixed base host.\nfunc (a *App) DoToken(ctx context.Context, token, method, path string, body any) (int, []byte, error) {\n\treturn a.do(ctx, \"Bearer \"+token, method, path, body)\n}\n\nfunc (a *App) do(ctx context.Context, authorization, method, path string, body any) (int, []byte, error) {\n\tif !strings.HasPrefix(path, \"/\") || strings.HasPrefix(path, \"//\") || strings.Contains(path, \"\\\\\") {\n\t\treturn 0, nil, fmt.Errorf(\"githubapp: request path must be a single-host absolute path\")\n\t}\n\tbase, err := url.Parse(a.baseURL)\n\tif err != nil || base.Scheme == \"\" || base.Host == \"\" || base.User != nil {\n\t\treturn 0, nil, fmt.Errorf(\"githubapp: invalid base URL\")\n\t}\n\trelative, err := url.ParseRequestURI(path)\n\tif err != nil || relative.IsAbs() || relative.Host != \"\" || relative.User != nil {\n\t\treturn 0, nil, fmt.Errorf(\"githubapp: invalid request path\")\n\t}\n\ttarget, err := url.Parse(a.baseURL + path)\n\tif err != nil || target.Scheme != base.Scheme || !strings.EqualFold(target.Host, base.Host) || target.User != nil {\n\t\treturn 0, nil, fmt.Errorf(\"githubapp: request path escaped configured host\")\n\t}\n\tvar reader io.Reader\n\tif body != nil {\n\t\tb, err := json.Marshal(body)\n\t\tif err != nil {\n\t\t\treturn 0, nil, fmt.Errorf(\"githubapp: marshal request: %w\", err)","sourceCodeStart":332,"sourceCodeEnd":368,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/3ee70a102609e550bd2e68004bf5990a9341c851/shared/platform/githubapp/githubapp.go#L332-L368","documentation":"The internal do() method SSRF-guards every request path before combining it with the configured base URL. A valid path must start with a single '/', must not start with '//', and must not contain backslashes. This error is thrown for any path argument passed to DoToken (or higher-level wrappers) that violates those rules.","triggerScenarios":"Calling DoToken(ctx, token, method, path, body) with path = \"repos/octocat/hello\" (missing leading '/'), \"//evil.com/api\" (protocol-relative), or any path containing '\\\\' such as \"repos\\\\octocat\".","commonSituations":"Building endpoint strings with string concatenation and forgetting the leading slash; copying Windows-style paths; accidentally interpolating a full URL into the path argument instead of passing it via baseURL.","solutions":["Ensure the path argument always starts with exactly one '/', e.g. \"/repos/\" + owner + \"/\" + name.","Never embed a host in the path — configure a.baseURL for the host and pass only the path portion.","Sanitize interpolated segments with url.PathEscape and strip backslashes before building the path.","Add a pre-call assertion in tests that rejects paths not matching ^/[^/]."],"exampleFix":"// before\nclient.DoToken(ctx, tok, \"GET\", \"repos/\" + owner + \"/\" + name, nil)\n// after\npath := \"/repos/\" + url.PathEscape(owner) + \"/\" + url.PathEscape(name)\nclient.DoToken(ctx, tok, \"GET\", path, nil)","handlingStrategy":"validation","validationCode":"func validAPIPath(p string) bool {\n    return strings.HasPrefix(p, \"/\") && !strings.HasPrefix(p, \"//\") && !strings.Contains(p, \"\\\\\")\n}\nif !validAPIPath(path) { return fmt.Errorf(\"bad path %q\", path) }","typeGuard":null,"tryCatchPattern":"if _, _, err := app.DoToken(ctx, tok, m, path, body); err != nil {\n    if strings.Contains(err.Error(), \"single-host absolute path\") {\n        // fix path construction; do not retry\n    }\n}","preventionTips":["Always build paths with a leading '/' via a helper, never raw concatenation.","Escape segments with url.PathEscape.","Never put hosts into the path argument."],"tags":["github","ssrf","url-validation","path"],"backgroundTag":"invalid-url-format","analyzedSha":"3ee70a102609e550bd2e68004bf5990a9341c851","analyzedAt":"2026-09-20T15:53:39.229Z","contentChangedAt":"2026-09-20T15:53:39.229Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}