Skip to content

Security & reliability hardening: SSRF, atomic writes, bounded URL replace, config caps#13

Merged
leonardomso merged 14 commits into
masterfrom
leonardomso/lyon-v1
May 17, 2026
Merged

Security & reliability hardening: SSRF, atomic writes, bounded URL replace, config caps#13
leonardomso merged 14 commits into
masterfrom
leonardomso/lyon-v1

Conversation

@leonardomso

Copy link
Copy Markdown
Owner

Summary

Hardens the link-checker against a set of security and reliability issues
found during a Senior Go Engineer / Security Reviewer style pass. Each
finding lands as its own commit with comprehensive table-driven tests.

Security

  • SSRF defense (b6993ff). Default-deny outbound TCP to loopback,
    RFC1918, link-local (incl. 169.254.169.254 IMDS), IPv6 unique-local,
    multicast, unspecified. Two layers: a URL-literal validator and a
    TCP-dial-time check that catches DNS rebinding. Redirects are
    re-validated per hop. Opt-in via --allow-private-hosts /
    output.allow_private_hosts for legitimate intranet docs.
  • Symlink traversal (9b8112c). Scanner skips symlinks during
    WalkDir; fixer refuses to rewrite any path whose Lstat reports a
    symlink. Prevents attacker-controlled links from redirecting writes
    (e.g. into ~/.ssh/authorized_keys).
  • Bounded FindAndLoad (23db942). Required stopAt argument
    bounds the upward .gonerc.yaml walk. Guards against silent
    pickup of configs from /tmp or shared parent dirs. Ancestor check
    defeats prefix collisions like /tmp/foo vs /tmp/foobar.

Reliability

  • Atomic writes (cdf2581). New internal/atomicfile does temp +
    fsync + rename. Used by internal/fixer and internal/output. A
    crash mid-gone fix can no longer leave a user's source file
    truncated or half-rewritten.
  • Bounded URL replacement (446251f). ApplyToFile previously
    used strings.ReplaceAll, silently corrupting any URL that had
    the fixed URL as a prefix. New replaceBoundedURL only matches when
    both sides are non-URL-continuation characters. 28 boundary
    scenarios covered.
  • Config upper bounds (2be1960). check.concurrency ≤ 1024,
    check.timeout ≤ 300s, check.retries ≤ 10. Prevents a typo from
    spawning 100k workers or hanging CI for a day.

Earlier branch work (pre-hardening)

  • 7eed113 ci: fix bounded fuzz workflow
  • 2d369ed fix(cmd): align interactive mode and clean lint
  • 6f05278 refactor(cmd): improve reliability and testability
  • 7c93ad9 fix(cmd): harden cli test coverage paths

Test plan

  • go build ./... clean
  • go test ./... — 1102 passed across 18 packages
  • New package internal/atomicfile — 12 tests including
    concurrent same-path "all-or-nothing" and 1 MiB payload
  • internal/checker SSRF coverage (safety_test.go): table-driven
    blocked-IP set, scheme rejection, URL validator, safe-dial wrapper
    for blocked literal IPs and DNS that resolves into private space,
    end-to-end redirect-chain refusal
  • internal/fixer URL-boundary table — 28 cases incl. unicode,
    tab, percent-encoding, preceding-char prefix attacks
  • internal/config — new bounds tests plus 6 stopAt boundary tests
  • Manual smoke: run gone check against a repo with a public
    URL that 302s to http://169.254.169.254/... and confirm it's
    reported as blocked

🤖 Generated with Claude Code

Add command-level integration and fuzz coverage across parser flows.
Fix config-driven structured output when no links are found or all links are ignored.
Fix redirect auto-fixes for duplicate URLs across files and make filter tracking race-safe.
Refactor check and fix into injected runners, expand command and UI tests, add end-to-end benchmarks and fixtures, and adopt pond/conc in checker and parser hot paths.
Route interactive through an injected runner, pass configured checker options into the UI path, and clear the repository lint backlog while keeping tests and integration coverage green.
…fixer

Scanner now skips symlinks during WalkDir (via d.Type()&os.ModeSymlink)
to prevent following links that point outside the user-specified scan
root.

Fixer now refuses to rewrite a path whose Lstat reports a symlink,
preventing an attacker-controlled symlink from redirecting writes onto
arbitrary files (e.g. ~/.ssh/authorized_keys) when the user runs
'gone fix' over a directory they don't fully control.

Tests cover both behaviours, with Unix-only skips.
…ta ranges

Default-deny outbound TCP to dangerous CIDRs (loopback 127.0.0.0/8 &
::1, RFC1918, link-local incl. 169.254.169.254 IMDS, IPv6 unique-local
fc00::/7, multicast, unspecified). Defense in depth:

  1. validateURL rejects http/https URLs whose literal host resolves
     into a blocked range and rejects non-http(s) schemes.
  2. safeDialContext wraps the underlying net.Dialer.DialContext so
     hostnames whose DNS resolution lands in a blocked range are
     refused at TCP-dial time. This catches DNS rebinding because the
     check happens after resolution, on each connection.
  3. followRedirectChain re-validates every hop, so a public URL that
     302s to http://169.254.169.254/latest/meta-data is blocked.

Users with legitimate intranet docs can opt in via --allow-private-hosts
or output.allow_private_hosts: true in .gonerc.yaml. Test suites that
hit httptest.NewServer (127.0.0.1) explicitly opt in via
WithAllowPrivateHosts(true) so the production default stays default-deny.

Adds internal/checker/safety_test.go with table-driven coverage:
blocked-IP set, scheme rejection, URL validator, safe dial wrapper
covering blocked literal IPs and DNS that resolves into private space,
and end-to-end redirect-chain refusal.
ApplyToFile previously called strings.ReplaceAll(content, OldURL, NewURL),
which would corrupt any URL in the file that contained OldURL as a prefix.
Concrete example: a file with both [a](https://x.com/a) and
[b](https://x.com/abc) being fixed for https://x.com/a -> https://y.com/a
would silently rewrite the second link to https://y.com/abc.

Replace with replaceBoundedURL: each candidate match is accepted only when
the bytes immediately before and after are not RFC 3986 path/query/fragment
continuation chars. The continuation set is deliberately narrow ('(', ')',
'[', ']', ',', ';', '\'' are excluded because they act as terminators in
markdown / JSON / YAML / HTML even though RFC 3986 reserves them).

Adds table-driven coverage of 28 boundary scenarios (alnum, path, query,
fragment, percent encoding, quotes, brackets, newlines, tabs, unicode,
preceding-char prefix attacks) plus three end-to-end ApplyToFile tests
exercising the longer-URL non-corruption guarantee.
A crash, SIGKILL, OOM, or disk-full during 'gone fix' previously left
user files truncated or half-rewritten — the worst possible outcome
for a tool whose explicit job is to edit other people's source files.
The same was true of --output report files.

Introduce internal/atomicfile.WriteFile:
  1. CreateTemp in the same directory (rename is only atomic within
     one filesystem)
  2. Write + fsync + close the temp file
  3. chmod to the requested perm
  4. Rename over the destination

If any step fails the temp file is removed; the destination is never
touched until the rename succeeds.

Apply at the two production write sites: internal/fixer/fixer.go and
internal/output/output.go.

Test coverage: 12 cases covering new file, overwrite, perm bits, empty
payload, leftover-temp leak detection, empty-path rejection, missing
directory cleanup, concurrent distinct paths, concurrent same path
(all-or-nothing), 1 MiB payload, and a reader-during-overwrite race
ensuring readers always observe full content (skipped on Windows where
rename-over-open differs).
…etries

Validate() previously rejected only negative values, so a typo or
malicious .gonerc.yaml of:

  check:
    concurrency: 100000
    timeout: 99999
    retries: 999

would spawn 100k HTTP workers, hang a CI job for ~28 hours per URL,
and retry each failure a thousand times. Add hard caps:

  - MaxConcurrency = 1024   (well above any legitimate parallelism)
  - MaxTimeout     = 300s   (5 min upper bound per request)
  - MaxRetries     = 10

Validation now rejects anything above the caps with a clear error that
names both the field and the limit. Adds nine new sub-tests:
ConcurrencyAboveMax / AtMaxOK, TimeoutAboveMax / AtMaxOK,
RetriesAboveMax / AtMaxOK, HugeConcurrencyRejected,
HugeTimeoutRejected, and an all-at-max combo.
FindAndLoad previously walked from startDir all the way to the
filesystem root looking for a .gonerc.yaml. Running 'gone' inside an
unrelated subdirectory of a shared or world-writable workspace
(/tmp/..., a co-located project, etc.) could silently pick up
config that altered behavior — different ignore rules, looser
allow_private_hosts, redirected output formats — without any signal
to the user.

Require an explicit stopAt boundary. The walk includes stopAt and
stops there; an empty stopAt collapses to startDir (no walk at all).
If stopAt isn't a genuine ancestor of startDir (e.g. a prefix
collision like /tmp/foo vs /tmp/foobar, or an unrelated sibling
directory) the search is restricted to startDir — never silently
redirected elsewhere.

Six new sub-tests cover: StopAtBoundsUpwardWalk,
EmptyStopAtRestrictsToStartDir, StopAtUnrelatedDirIsRestrictiveNotEscape,
StopAtPrefixCollisionRejected, StopAtEqualsStartDirNoWalk, and a
direct table-driven test of isAncestorOrEqual including the
/a vs /ab prefix-collision case.
@leonardomso leonardomso force-pushed the leonardomso/lyon-v1 branch from 23db942 to c7cce3e Compare May 17, 2026 15:49
Bump module Go directive to 1.26.3 and the CI workflow toolchains to
match. Refresh all direct and indirect dependencies to their latest
patch/minor releases via `go get -u ./...` + `go mod tidy`.
Rename replaceBoundedURL return values so gocritic stops flagging
unnamed multi-return results, and run gofmt on safety.go and
fixer.go. No behavior change.
Add Checker.Close which calls http.Client.CloseIdleConnections so
the underlying transport's readLoop and writeLoop goroutines exit
once a test finishes. Register t.Cleanup(checker.Close) on every
test site so goleak.VerifyTestMain no longer fails on leaked
net/http persistConn goroutines.

Also break a few long fluent-builder lines so revive's
line-length-limit (120) stops tripping.
# Conflicts:
#	cmd/check_runner.go
#	cmd/e2e_bench_test.go
#	cmd/fix_runner.go
#	cmd/helpers_test.go
#	cmd/integration_test.go
#	cmd/interactive_runner.go
#	go.mod
#	go.sum
#	internal/fixer/fixer.go
#	internal/scanner/scanner_test.go
@leonardomso leonardomso merged commit 545ec43 into master May 17, 2026
2 checks passed
@leonardomso leonardomso deleted the leonardomso/lyon-v1 branch May 17, 2026 16:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant