Skip to content

fix: validate directory hrefs per segment so sighted mode recurses again - #83

Open
LuvW-Q wants to merge 1 commit into
WangYihang:mainfrom
LuvW-Q:fix/add-folder-subdir-recursion
Open

fix: validate directory hrefs per segment so sighted mode recurses again#83
LuvW-Q wants to merge 1 commit into
WangYihang:mainfrom
LuvW-Q:fix/add-folder-subdir-recursion

Conversation

@LuvW-Q

@LuvW-Q LuvW-Q commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #82.

1.1.8's sighted (directory-listing) mode skips every subdirectory of the listing:

WARNING Skipping unsafe directory listing entry: '/.git/objects/'

The temp repo ends up with only top-level files, so git clone fails and copy_useful_files() crashes with FileNotFoundError. Every listing-enabled target fails on 1.1.8.

Root cause: 5f2a8ba (the GHSA-hr3m-4qwq-3mgc fix) passes the whole directory href to _is_safe_path_segment, but that gate is a single-segment allowlist (^[A-Za-z0-9._\-+@]+\Z) while a directory href is a multi-segment path (/.git/objects/) — the match always fails.

Fix (githacker/__main__.py, add_folder): resolve the directory href with urljoin (listings may emit absolute hrefs), require it to stay inside the origin and the .git/ crawl anchor (a same-origin absolute href like /etc/ must not be followed), split it into segments, and validate each segment with the existing _is_safe_path_segment — the same per-segment approach the file branch already uses. Traversal (..), NUL, cross-origin, and off-anchor entries are all still rejected.

Tests

Added two regression tests in tests/test_add_folder_traversal.py:

  • test_add_folder_recurses_into_benign_subdirectories — benign dir entries (objects/, /.git/objects/, refs/heads/) must issue a second fetch (recursed into), pinned by request count.
  • test_add_folder_recursion_queues_files_from_sublisting — files listed in a subdirectory's own listing are queued anchored under temp_dst, no .., no escape.

The existing 22-payload traversal corpus and all other security tests pass unchanged:

191 passed
ruff check: passed
ruff format: clean

Verified RED on 1.1.8 (benign recursion tests fail; traversal tests keep passing), GREEN with the fix.

Commit 5f2a8ba (the GHSA-hr3m-4qwq-3mgc fix) hardened add_folder's
recursion branch by passing the whole directory href to
_is_safe_path_segment. That gate is a single-segment allowlist
(^[A-Za-z0-9._\-+@]+\Z), while a directory href is a multi-segment
path ('/.git/objects/'), so the match always fails: every subdirectory
of a directory listing is skipped with "Skipping unsafe directory
listing entry", the rebuilt repo has no objects/refs, `git clone` fails
with "repository does not exist", and copy_useful_files then crashes
with FileNotFoundError. Any listing-enabled (sighted) target fails on
1.1.8.

Resolve the directory URL with urljoin (listings may emit absolute
hrefs), require it to stay inside the origin and the .git/ crawl
anchor, split it into segments, and validate each segment with the
existing _is_safe_path_segment gate before recursing — the same
per-segment approach the file branch already uses. Traversal ('..'),
NUL, and cross-origin entries are still rejected; an absolute href
like '/etc/' is now additionally confined to the .git/ anchor.

Add regression tests: benign subdirectory entries must be recursed
into ('objects/', '/.git/objects/', 'refs/heads/'), and files listed
in a subdirectory's own listing must be queued anchored under
temp_dst.

Fixes WangYihang#82
WangYihang pushed a commit that referenced this pull request Sep 8, 2026
…recursion

Builds on #83 (merged into this branch, so its commit and tests are kept).

add_folder resolved directory and file entries separately — the file branch
with urljoin, the directory branch by string concatenation plus a check meant
for a single path segment. Two implementations of one operation drifted, and
that drift was issue #82. #83 makes the two branches more alike; this makes
them one.

_resolve_href takes an href and returns the URL to fetch together with the
path components to write, or None. Deriving both from one resolution is what
stops them disagreeing again. It applies four checks in order: same origin,
under the .git/ anchor, decodes to at least one segment, and every segment
passes _is_safe_path_segment. Hrefs are now unquoted before validation, so a
percent-encoded separator is judged as what it decodes to rather than as
literal text.

Fixes three findings the new suites pinned:

- C5 (off-tree crawl): the file branch checked the origin but not the anchor,
  so a listing could steer the crawl at any same-origin path — /private/
  CREDENTIALS was fetched into the operator's output. The anchor check now
  applies to both kinds of entry.
- C6 (infinite listing): no visited set and no depth cap. A server answering
  every URL with a one-subdirectory listing drove the crawl until the path
  outgrew the filesystem limit — 131 requests, then an unhandled OSError.
  A visited-URL set stops a listing that links to itself (the URL never
  grows); _MAX_CRAWL_DEPTH stops one that grows the URL each step.
- Listing self-links (Apache's ?C=N;O=D, in-page anchors) resolved back to
  the listing URL and were queued as a file, costing a request per listing.

Verified against a real evil_server with local GitHacker (Docker is
unavailable here, so the containerised leg is still unrun):

                           before          after
  C5_offtree_crawl         FAIL            PASS   (7 requests, no /private/)
  C6_infinite_listing      FAIL, 131 req   PASS   (38 requests)
  A1 / C1 / C4 / A7        PASS            PASS   (no regression)

Against the absolute-href listing server, GitHacker went from skipping every
subdirectory and recovering 0 files to "1 / 1 were exploited successfully"
with 26 files — issue #82, end to end.

Eight xfail(strict) markers flipped to XPASS and were removed, which is the
mechanism working as intended. The `....//` expectation moved from
".git/....//" to ".git/..../" because urljoin normalises the doubled slash
that string concatenation used to leave. The runaway-detector meta-test no
longer depends on the crawler being broken: it drives the scripted server
directly.

Two legacy test helpers build a GitHacker with __new__ and needed the new
instance attributes added by hand — the coupling the conftest fixture avoids.

292 passed, 16 xfailed. ruff check and format clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fxyt81cRHQ4ewH5Gqrtb45
WangYihang pushed a commit that referenced this pull request Sep 8, 2026
…recursion

Builds on #83 (merged into this branch, so its commit and tests are kept).

add_folder resolved directory and file entries separately — the file branch
with urljoin, the directory branch by string concatenation plus a check meant
for a single path segment. Two implementations of one operation drifted, and
that drift was issue #82. #83 makes the two branches more alike; this makes
them one.

_resolve_href takes an href and returns the URL to fetch together with the
path components to write, or None. Deriving both from one resolution is what
stops them disagreeing again. It applies four checks in order: same origin,
under the .git/ anchor, decodes to at least one segment, and every segment
passes _is_safe_path_segment. Hrefs are now unquoted before validation, so a
percent-encoded separator is judged as what it decodes to rather than as
literal text.

Fixes three findings the new suites pinned:

- C5 (off-tree crawl): the file branch checked the origin but not the anchor,
  so a listing could steer the crawl at any same-origin path — /private/
  CREDENTIALS was fetched into the operator's output. The anchor check now
  applies to both kinds of entry.
- C6 (infinite listing): no visited set and no depth cap. A server answering
  every URL with a one-subdirectory listing drove the crawl until the path
  outgrew the filesystem limit — 131 requests, then an unhandled OSError.
  A visited-URL set stops a listing that links to itself (the URL never
  grows); _MAX_CRAWL_DEPTH stops one that grows the URL each step.
- Listing self-links (Apache's ?C=N;O=D, in-page anchors) resolved back to
  the listing URL and were queued as a file, costing a request per listing.

Verified against a real evil_server with local GitHacker (Docker is
unavailable here, so the containerised leg is still unrun):

                           before          after
  C5_offtree_crawl         FAIL            PASS   (7 requests, no /private/)
  C6_infinite_listing      FAIL, 131 req   PASS   (38 requests)
  A1 / C1 / C4 / A7        PASS            PASS   (no regression)

Against the absolute-href listing server, GitHacker went from skipping every
subdirectory and recovering 0 files to "1 / 1 were exploited successfully"
with 26 files — issue #82, end to end.

Eight xfail(strict) markers flipped to XPASS and were removed, which is the
mechanism working as intended. The `....//` expectation moved from
".git/....//" to ".git/..../" because urljoin normalises the doubled slash
that string concatenation used to leave. The runaway-detector meta-test no
longer depends on the crawler being broken: it drives the scripted server
directly.

Two legacy test helpers build a GitHacker with __new__ and needed the new
instance attributes added by hand — the coupling the conftest fixture avoids.

292 passed, 16 xfailed. ruff check and format clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fxyt81cRHQ4ewH5Gqrtb45
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.1.8 regression: all subdirectories skipped in directory-listing mode ('Skipping unsafe directory listing entry'), git clone always fails

1 participant