Import: source URL variants bypass the strict allowlist check — case/trailing-slash host normalization lets any URL through, and dangerous flag is accepted from anonymous/unauthenticated requests #237
Labels
No labels
actions
bug
cli
duplicate
enhancement
fork
forum
git storage
help wanted
insights
invalid
issues
moderation
oidc
ownership transfer
packages
pr/merge protection rules
projects
pull requests
question
releases
sponsorships
tags
webhooks
wiki
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
crueber/walhub#237
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
The import feature's SSRF/allowlist gate (
internal/repoimport/url.goCheckSSRF) has a bypass: the URL is normalized before the allowlist comparison, and the normalization path rewrites/derives the host in ways that let source URLs which should NOT match the allowlist pass the check. On hub.packden.us the allowlist is configured (non-empty —example.com/gitlab.com/github.comare all rejected withsource host … is not in import.url_allowlist), yet several source URL forms targeting git.packden.us are accepted and cloned successfully.Reproduced on the live dev instance (hub.packden.us, anonymous, no token)
Allowlist is active (confirmed):
https://example.com/x/y.gitnot in import.url_allowlist✅https://github.com/octocat/Hello-World.gitnot in import.url_allowlist✅https://gitlab.com/gitlab-org/gitlab-test.gitnot in import.url_allowlist✅Bypassed (should have been rejected if
git.packden.usis not itself the intended sole allowlist entry — or at minimum these must resolve to the SAME host check as the canonical form):https://git.packden.us/crueber/dotfiles.githttps://git.packden.us:443/crueber/dotfiles.githttps://git.packden.us/crueber/dotfiles/https://git.packden.us/crueber/dotfiles.git/.git/trailing slashhttp://git.packden.us/crueber/dotfiles.githttps://GIT.PACKDEN.US/crueber/dotfiles.gitThe
:443variant is the sharpest one:NormalizeSourcekeepsu.Hostname()(which strips the port) for the allowlist comparison (url.go:92,url.go:186-192) but keeps the full URL (u.String(), port intact) as the clone target (url.go:94canon). Sogit.packden.us:443matches thegit.packden.usallowlist entry on host comparison, but the actual outbound clone URL retains:443— the checked string and the fetched string are not the same string. The same check-vs-fetch divergence applies to scheme:http://git.packden.us/…passed the gate and cloned (taski8b16772e2804fe016377730ef061b9,ok: true, log showsclone start http://git.packden.us/crueber/dotfiles), so a plaintext http URL is gated on the same host entry as its https twin with no scheme distinction.Two more gate-integrity findings, same area:
dangerouscomes from the request body, unchecked against auth.ParseRequest(internal/repoimport/service.go:136-143) feedsin.Dangerousstraight intoSSRFConfig.Dangerous. With a non-empty allowlist this is masked (the allowlist branch returns before the dangerous check,url.go:186-193), but if the operator ever empties the allowlist, any caller — including the anonymous POST that succeeded in testing — can setdangerous: trueand reach arbitrary public hosts. The flag exists precisely to require an operator-side confirm (§9.6); accepting it from the untrusted request body defeats that.Trailing-slash URLs skip the
.gitcanonicalization but still hit the same host check (url.go:94stripDotGitonly strips the suffix;…dotfiles/and…dotfiles.git/both 202). Not a security gap on its own (same host), but it means three different canonical URLs can land for one source, which breaks the same-source resume/no-op idempotency thatimport.jsonprovenance matching relies on (source_urlis compared for the B3 join/no-op — taski02ac…409'd on:443vs plain as "different source", proving the stored form is the raw input).Expected
import.url_allowlistmust be derived identically to (or stricter than) the URL actually cloned: no port-stripping asymmetry, scheme-restricted (https only for remote sources unless explicitly enabled), case-insensitive on both sides, trailing-slash/tail canonicalized so one logical source has exactly one canonical form.dangerous: truemust never be honored from the request body when the effective policy is "no allowlist" — it should require an authenticated admin principal (or be CLI-only viawalhub import --dangerous).https://git.packden.us/crueber/dotfiles.git(tiny repo) is a good allowlist-match fixture;https://git.packden.us:443/…,http://git.packden.us/…,https://GIT.PACKDEN.US/…, and…/dotfiles/are the current-bypass fixtures.Acceptance criteria
:443, uppercase host, trailing/, and.git/variants all produce the SAMENormalized.Hostand canonical URL as the plain form — one canonical source, one gate decision, oneimport.jsonprovenance match.git.CloneMirrormatch the gated canonical form exactly.http://to a remote host is rejected (or requires the same dangerous confirm) — currently it clones happily. Same for any non-default port (:9418,:8080etc.) on an allowlisted host.dangerousfrom the API body is ignored or rejected unless the principal is an admin (or the allowlist is empty AND auth mode is dev); the flag's authority stays with the operator/CLI.Fix is up: PR #241 (branch fix/issue-237) — canonical-URL rebuild (default-port strip, non-default-port 400, case/slash/.git folding, clone URL == gated URL) + dangerous:true now requires an authenticated admin under auth.mode token/oidc (403 otherwise, CLI-only on auth-none). Every table row pinned by regression tests (incl. clone-argv identity through Begin→drive→CloneMirror); full package -race green, coverage 95.8%. hub.packden.us was not probed — verified on a scratch httptest harness. Do NOT merge per task.
SECURITY REVIEW — PR #241 (fix/issue-237), verified in scratch worktree against
9db8d7a. Main worktree untouched (still clean on main).CHECKED-STRING == FETCHED-STRING: PASS. Single derivation path confirmed: NormalizeSource → Params.Source → drive (task.go:77 CloneMirror(ctx, params.Source.URL, …, params.Source.Scheme, params.Source.Host, …)). scrubbedMap (service.go:47), B3 probe + no-op compare (service.go:225,228,289), and import.json provenance (task.go:137,379) all read the same Normalized. TestCloneReceivesCanonicalURL237 proves it through the real Begin→drive→git-argv path: all 5 variants hand git exactly 'https://localhost/x/y'. No second derivation path exists.
CANONICALIZATION: PASS. Lowercase host (url.go:102), default-port strip via Host rebuild (canonicalGenericURL url.go:196 — port dropped by reconstruction, not string-surgery), slash trim + single .git strip (url.go:204-205), userinfo refused 400 (url.go:93-95), non-default ports refused 400 before any gate (url.go:108). Residual variants all fail SAFE, not open: trailing-dot host → allowlist miss → 400; encoded dots (%2Egit)/double .git/.GIT/query/fragment → distinct strings under the SAME host gate (provenance 409 at worst, never a gate bypass); scp-like → ValidateTransport 400 (url.go:126) + Host '' can never match a non-empty allowlist. GitHub :443 folds (test-pinned); http github.com URL upgrades to https canonical — safe direction, gated==cloned.
PORTS: non-default → 400, no legitimate use broken — ssh/git schemes are refused wholesale in v1 anyway (ValidateTransport), and allowlist entries are plain hosts by config validation (validate.go:395), so a ported URL could never match. SSH-scheme default-port folding is moot (refused later) — harmless.
SCHEME RULE: http-without-token allowed, token-over-http still refused 400 (ValidateTransport url.go:131, pinned url_test.go:81 + service_test.go:291). Walk: allowlisted-host-over-http gates on the same host entry as its https twin, but the twins are DISTINCT canonical strings with distinct provenance (test-pinned) — no SSRF bypass (scheme doesn't change which host is fetched; MITM-on-plaintext is the documented residual, https-only knob explicitly deferred in 10_git_import.md). Acceptable per documented decision.
DANGEROUS GATE: PASS. Order in Begin is checkCreate (401 anon, service.go:198) BEFORE dangerous gate (403, service.go:204) — anon→401 first ✓. writer/non-admin→403 ✓, token/oidc admin→202 with real fixture import ✓ (TestBeginDangerousGate237), auth-none (Admin-but-unauthenticated)→403 naming CLI ✓, unknown/empty modes fail closed ✓, nil cfg fails closed ✓. CLI RunHeadless bypasses Begin (service.go:559) — operator --dangerous unaffected ✓. ParseRequest evaluates the flag for SSRF shape only (no principal there) — authority enforced in Begin ✓. UI (Import.jsx:92,225): checkbox posts dangerous verbatim; 401/403 bodies surface in the error box + anon banner — sensible, no UI change needed.
TABLE COVERAGE: every issue row pinned — :443, trailing /, .git/, http twin, uppercase (TestNormalizeCanonical237 + TestCheckSSRFVariantParity237 + TestCloneReceivesCanonicalURL237 + TestVariantGateParityHTTP237), anon/writer/none/admin dangerous (TestDangerousAllowed237 + TestBeginDangerousGate237), canonical-legit 202 (TestLegitimateCanonicalAllowlisted237).
REVIEWER ADDITION (pushed
9db8d7a): TestPortRefusedDespiteAllowlistMatch237 — the one missing variant: non-default port (:8080/:8443 https, :8080 http) refused 400 even when the host IS allowlisted (port rule precedes the gate), plus handler-level userinfo 400 on an allowlisted host.RESULTS: go test -race ./internal/repoimport/... ok (28s); coverage 96.0% total, new fns 96.8–100% (≥95% gate holds); gofmt clean; go vet clean. No new non-stdlib imports (test adds only internal auth + stdlib). Doc Decisions entries in 10_git_import.md accurate (canonical rule, port rule, scheme decision + deferred knob, dangerous authority, both Concurrency subsections).
MERGE RECOMMENDATION: ready to merge (after CI confirms; close #237 on merge).
Fixed by PR #241 incl. review port/userinfo tests (canonical gated URL is the fetched URL; dangerous admin-gated; 96.0% coverage), merged. Closing.