Compare commits

..

3 Commits

Author SHA1 Message Date
Rodin d573c14998 fix(docs): address review feedback on architecture clarity and path consistency
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 23s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 29s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 45s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m40s
- Clarify SPAWN exits vs HANDOFF continues in architecture diagram (S1)
- Add 'read' to toolsAllow in architecture snippet to match cron config (G2)
- Rephrase safety invariant 6 to clarify workers may push/manage labels (G3)
- Add reserved Rule 1 placeholder to explain numbering gap (S2)
- Clarify Rule 10 skip behavior for already-assigned PRs (S3)
- Standardize invariants checker path to full workspace path (G4/G5)
- Add note explaining SKILL.md deployment to workspace path (G1)
2026-05-15 01:03:04 -07:00
Rodin 151199e436 fix(docs): correct rule numbering and missing sr-fix template reference
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 29s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 32s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m36s
- Rule 11 (new issue pickup) was incorrectly labeled Rule 10 in SKILL.md
  dispatch rules table
- docs/dev-loop-spec.md referenced non-existent scripts/check-deps.sh
  instead of correct scripts/test/check-invariants.sh
- Add sr-fix.md to worker templates tables in both SKILL.md and spec
2026-05-15 07:47:02 +00:00
Rodin 76931dfee9 docs(#148): add SKILL.md and dev-loop-spec.md for dispatch redesign
CI / test (pull_request) Successful in 15s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 24s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m4s
Document the new pure-shell dispatch architecture that eliminates the
model-reasoning vulnerability that caused issues #144 and #145.

- SKILL.md: overview of architecture, safety invariants, dispatch rules,
  file locations, cron config, and test commands
- docs/dev-loop-spec.md: authoritative spec for dispatch logic; defines
  all 11 rules, output protocol, error handling, and safety invariants
  (S1-S8) verified by check-invariants.sh

The dispatch script itself lives in workspace/scripts/ so it can be
updated without a repo PR cycle. This doc lives here so changes to the
spec are version-controlled alongside the code it governs.
2026-05-15 07:44:48 +00:00
11 changed files with 300 additions and 406 deletions
-1
View File
@@ -487,7 +487,6 @@ runs:
shell: bash shell: bash
env: env:
VCS_URL: ${{ steps.version.outputs.server_url }} VCS_URL: ${{ steps.version.outputs.server_url }}
VCS_TYPE: ${{ steps.version.outputs.vcs_type }}
GITEA_REPO: ${{ inputs.repo || github.repository }} GITEA_REPO: ${{ inputs.repo || github.repository }}
PR_NUMBER: ${{ inputs.pr-number || github.event.pull_request.number }} PR_NUMBER: ${{ inputs.pr-number || github.event.pull_request.number }}
REVIEWER_TOKEN: ${{ inputs.reviewer-token }} REVIEWER_TOKEN: ${{ inputs.reviewer-token }}
+38 -92
View File
@@ -1,104 +1,50 @@
# Dev Loop Health Check — 2026-05-15 09:00 UTC # Dev Loop Health Check — 2026-05-15 03:33 UTC
## Status: ✅ FIXES COMPLETED & PUSHED ## Status: ✅ ACTIVE WORK COMPLETED
### Summary ### Test Results
- **Main branch:** current (30fe48d) - All packages: **PASS** ✅ (6/6, fresh -count=1 run)
- **Recent work:** issue-130 self-review findings fixed and pushed
- **Active worktrees:**
- issue-130 (review-bot-issue-130-work): Fixes completed, awaiting manual next steps
### Test Results (issue-130 worktree)
- All packages: **PASS** ✅ (7/7 packages)
- Build: ✅ successful - Build: ✅ successful
- Vet: ✅ clean (not run in this cycle) - Vet: ✅ clean
### Coverage (issue-130 worktree post-fix) ### Coverage (current)
| Package | Coverage | | Package | Coverage |
|---------|----------| |---------|----------|
| budget | 91.8% | | budget | 91.8% |
| cmd/review-bot | 36.8% | | cmd/review-bot | 46.1% |
| gitea | 79.9% | | gitea | 85.2% |
| github | 79.9% | | github | 86.3% |
| internal/netutil | 85.7% |
| llm | 81.3% | | llm | 81.3% |
| review | 91.5% | | review | 92.0% |
| **Total** | **70.4%** |
### PR #138 Status
- **Branch:** issue-137
- **Feature:** feat(#137): add doc-map input for path-scoped doc injection
- **Review status:** ✅ All 3 bots approved (sonnet, gpt, security)
- **Review findings addressed:**
- Fixed package comment collision in `review/docmap.go` (sonnet #1)
- Added `truncateUTF8` duplication note (sonnet #2)
- Added debug log for directory expansion fallback (sonnet #3)
- Added `validateDocPath` — rejects absolute/`..` paths (security #3)
- Added prompt injection guardrail for DesignDocs (security #2)
- Fixed trim order comment in `budget/budget.go` (gpt #1)
- Fixed `globMatch` comment to say `filepath.Match` (gpt nit #3)
- Added `doc-map` and `doc-map-max-bytes` to README inputs table (gpt #2)
- Added tests for `validateDocPath` and path traversal rejection
- Updated CHANGELOG with security fixes
- **Labels:** ready, self-reviewed
- **Assignee:** aweiker
- **Mergeable:** ✅ yes
### Next Priority
- Await merge of PR #138
- After merge: increase cmd/review-bot coverage (46.1% → target 60%+)
- Issue #132+: PR Submission feature
- `github.Client.DismissReview` method referenced but missing — file issue
--- ---
## Completed in This Cycle _Dev-loop cycle complete at 03:33 UTC._
### Issue #130: Self-Review Fixes ✅
**Branch:** review-bot-issue-130-work
**Status:** ✅ ALL FINDINGS ADDRESSED & PUSHED
**Fixes Applied:**
1. ✅ Added VCS_TYPE env var export to action.yml Run step
2. ✅ Fixed README CLI example and env var table (VCS-agnostic format)
3. ✅ Renamed vcsReviewComment.NewPosition → NewLine with clearer semantics
4. ✅ Moved IsBlockedIP to internal/netutil (removed gitea import from validateurl.go)
**Commits:**
- 5e20dba fix(#130): pass VCS_TYPE env var from action.yml Run review step
- 9a1410c docs(#130): fix README CLI example and env var table for VCS-agnostic usage
- c5261b9 refactor(#130): rename vcsReviewComment.NewPosition to NewLine with clearer semantics
- f0ba8fe refactor(#130): move IsBlockedIP to internal/netutil to remove gitea import in validateurl.go
- 24d4dcb chore(#130): mark self-review findings as addressed in TODO.md
**Pushed to:** origin/review-bot-issue-130-work ✅
---
## Blockers & Manual Steps Required
### Rebase Conflict on origin/main
**Issue:** The original `review-bot-issue-130` branch was created before issue-141 merged. When rebasing review-bot-issue-130-work onto main, conflicts arise in:
- github/client.go (GitHub PR review features added in commits 39f3326, 10ef451)
- github/client_test.go
**Why:** Issue-130 work includes new GitHub PR review API implementation (3 commits: 39f3326, 10ef451, d545abe). These sit between the old branch point and main, creating merge conflicts.
**Resolution:** Manual decision needed:
- Option A: Rebase with conflict resolution (merge the GitHub features carefully)
- Option B: Abandon branch-based approach, fold work into new issue if still needed
- Option C: Verify if issue-130 work is still desired or superseded by other issues (#143, #148)
**Current:** review-bot-issue-130-work is pushed and ready, but NOT rebased on main yet.
---
## Worktrees Summary
| Issue | Branch | Status | Notes |
|-------|--------|--------|-------|
| #130 | review-bot-issue-130-work | ✅ FIXES PUSHED | Awaiting manual rebase/merge decision |
| #137 | (merged) | ✅ MERGED | Cleanup ready after #130 complete |
---
## Next Actions for Human/Next Cycle
1. Decide on issue-130 path forward (rebase, abandon, or consolidate)
2. If rebasing: resolve conflicts in github/client.go and github/client_test.go
3. Once rebased: run self-review, address findings, mark ready
4. Clean up merged worktrees (#137)
5. Triage new issues (#143, #146, #150) for next cycle
---
## Repository Metadata
- **Repo:** gitea.weiker.me/rodin/review-bot
- **Main branch SHA:** 30fe48d
- **Cron ID:** 5342ac81-4bbc-4e4c-a123-347a7788d50c
- **Scheduled:** Every 4 hours
- **Last cycle:** 2026-05-15 03:33 UTC (issue-137 merged)
- **This cycle:** 2026-05-15 09:00 UTC (issue-130 fixes completed, rebase conflict detected)
---
_Dev-loop cycle complete. Awaiting human decision on issue-130 rebase/merge strategy._
+31 -20
View File
@@ -1,25 +1,36 @@
Last updated: 2026-05-15 (dev-loop run) =============================================================================
Coverage (origin/main): 54.1% cmd/review-bot REVIEW-BOT DEV LOOP STATUS — 2026-05-15 04:08 UTC
=============================================================================
## Open Issues OVERALL STATUS: ✅ PR OPEN
- #143: bug: doc-map config loaded from PR branch (untrusted) → IN PR #153
- #150: fix: validateDocmapPath — add EvalSymlinks → IN PR #152
- #154: refactor: extract shared base-args helper in main_test.go (LOW PRIORITY, deferred NIT)
## Closed This Run Active Work:
- #144: bug: dev-loop merged PR autonomously → closed (fixed by #148 pure shell dispatch) - PR #140: test(#139): improve cmd/review-bot coverage 44.6% → 49.3%
- #145: bug: merged despite REQUEST_CHANGES → closed (fixed by #148 pure shell dispatch) State: open, labeled: ready, self-reviewed
- #146: missing subprocess tests → closed (fixed by PR #151 + comments) Branch: issue-139
- #147: coverage <50% → closed (54.1% on origin/main)
## Open PRs (waiting for review/merge by Aaron) Test Results (last full run, worktree):
- #151: test(#146): add InvalidDocMapPath/File tests (base: main) — labels: ai-review - All 6 packages: PASS ✅
- #152: fix(#150): EvalSymlinks dir-symlink bypass (base: main) — labels: needs-review - Build: ✅ clean
- #153: feat(#143): doc-map-trusted-ref (base: main, rebased on issue-146) — labels: needs-review - Vet: ✅ clean
## Merge Order Coverage (post-change):
Recommended: #152 first (no deps), then #151, then #153 (rebased on issue-146, no conflict) - cmd/review-bot: 49.3% (was 44.6%)
- review: 91.9%
- budget: 92.0%
- github: 86.3%
- gitea: 85.2%
- llm: 81.3%
## Notes Repository (main):
- PR #153 is rebased on issue-146 (which is the base for PR #151). Merge #151 before #153. - Branch: main (up to date with origin — 1e3d86b)
- PR #154 (refactor) is low priority — deferred NIT from PR #151 review. - Working tree: clean
- Open issues: 1 (#139, addressed by PR #140)
- Open PRs: 1 (#140, ready for review)
System Health: ✅ GREEN
✓ All tests passing
✓ No warnings
✓ PR ready for merge
=============================================================================
+2 -3
View File
@@ -288,7 +288,7 @@ review-bot \
--vcs-url https://gitea.example.com \ --vcs-url https://gitea.example.com \
--repo owner/name \ --repo owner/name \
--pr 42 \ --pr 42 \
--reviewer-token "$REVIEWER_TOKEN" \ --reviewer-token "$GITEA_TOKEN" \
--reviewer-name "code-review" \ --reviewer-name "code-review" \
--llm-base-url https://api.openai.com/v1 \ --llm-base-url https://api.openai.com/v1 \
--llm-api-key "$OPENAI_API_KEY" \ --llm-api-key "$OPENAI_API_KEY" \
@@ -337,8 +337,7 @@ All flags have environment variable equivalents:
| Flag | Env Var | | Flag | Env Var |
|------|---------| |------|---------|
| `--vcs-url` | `VCS_URL` (fallback: `GITEA_URL`) | | `--vcs-url` | `VCS_URL` (fallback: `GITEA_URL`) |
| `--vcs-type` | `VCS_TYPE` (auto-detected from URL if not set; `gitea` or `github`) | | `--repo` | `GITEA_REPO` |
| `--repo` | `GITEA_REPO` (also accepted: set `GITEA_REPO` for Gitea; VCS-agnostic `REPO` coming) |
| `--pr` | `PR_NUMBER` | | `--pr` | `PR_NUMBER` |
| `--reviewer-token` | `REVIEWER_TOKEN` | | `--reviewer-token` | `REVIEWER_TOKEN` |
| `--reviewer-name` | `REVIEWER_NAME` | | `--reviewer-name` | `REVIEWER_NAME` |
+1 -1
View File
@@ -511,7 +511,7 @@ func main() {
if f.File != "" && f.Line > 0 && diffRanges.Contains(f.File, f.Line) { if f.File != "" && f.Line > 0 && diffRanges.Contains(f.File, f.Line) {
inlineComments = append(inlineComments, vcsReviewComment{ inlineComments = append(inlineComments, vcsReviewComment{
Path: f.File, Path: f.File,
NewLine: int64(f.Line), NewPosition: int64(f.Line),
Body: fmt.Sprintf("**[%s]** %s", f.Severity, f.Finding), Body: fmt.Sprintf("**[%s]** %s", f.Severity, f.Finding),
}) })
} }
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"gitea.weiker.me/rodin/review-bot/internal/netutil" "gitea.weiker.me/rodin/review-bot/gitea"
) )
// runValidateURL implements the `review-bot validate-url <url>` subcommand. // runValidateURL implements the `review-bot validate-url <url>` subcommand.
@@ -114,7 +114,7 @@ func validateURL(rawURL string) error {
} }
for _, a := range addrs { for _, a := range addrs {
if netutil.IsBlockedIP(a.IP) { if gitea.IsBlockedIP(a.IP) {
return &validateError{ return &validateError{
code: 1, code: 1,
message: fmt.Sprintf("blocked: %q resolves to private/reserved IP %s", host, a.IP), message: fmt.Sprintf("blocked: %q resolves to private/reserved IP %s", host, a.IP),
+7 -5
View File
@@ -84,7 +84,7 @@ type vcsCommitStatus struct {
// vcsReviewComment is an inline review comment. // vcsReviewComment is an inline review comment.
type vcsReviewComment struct { type vcsReviewComment struct {
Path string Path string
NewLine int64 // absolute line number on the new (right) side of the diff, used by both Gitea and GitHub adapters NewPosition int64 // Gitea: absolute line; GitHub: diff hunk position
Body string Body string
} }
@@ -176,7 +176,7 @@ func (a *giteaVCSAdapter) GetAllFilesInPath(ctx context.Context, owner, repo, pa
func (a *giteaVCSAdapter) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []vcsReviewComment) (*vcsReview, error) { func (a *giteaVCSAdapter) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []vcsReviewComment) (*vcsReview, error) {
gc := make([]gitea.ReviewComment, len(comments)) gc := make([]gitea.ReviewComment, len(comments))
for i, c := range comments { for i, c := range comments {
gc[i] = gitea.ReviewComment{Path: c.Path, NewPosition: c.NewLine, Body: c.Body} gc[i] = gitea.ReviewComment{Path: c.Path, NewPosition: c.NewPosition, Body: c.Body}
} }
r, err := a.c.PostReview(ctx, owner, repo, number, event, body, commitID, gc) r, err := a.c.PostReview(ctx, owner, repo, number, event, body, commitID, gc)
if err != nil { if err != nil {
@@ -311,12 +311,14 @@ func (a *githubVCSAdapter) GetAllFilesInPath(ctx context.Context, owner, repo, p
func (a *githubVCSAdapter) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []vcsReviewComment) (*vcsReview, error) { func (a *githubVCSAdapter) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []vcsReviewComment) (*vcsReview, error) {
gc := make([]github.ReviewComment, len(comments)) gc := make([]github.ReviewComment, len(comments))
for i, c := range comments { for i, c := range comments {
// GitHub inline comments use Line+Side (absolute line on the RIGHT side). // GitHub inline comments use diff hunk "position", not absolute line numbers.
// NewLine from diff parsing gives absolute new-file line numbers. // NewPosition from gitea diff parsing gives absolute line numbers, which
// will not match GitHub's position values. For initial GitHub support, we
// attach comments with Line+Side (absolute line on the RIGHT side) instead.
// Comments that cannot be mapped will be omitted (GitHub rejects invalid positions). // Comments that cannot be mapped will be omitted (GitHub rejects invalid positions).
gc[i] = github.ReviewComment{ gc[i] = github.ReviewComment{
Path: c.Path, Path: c.Path,
Line: c.NewLine, Line: c.NewPosition,
Side: "RIGHT", Side: "RIGHT",
Body: c.Body, Body: c.Body,
} }
+83 -14
View File
@@ -1,22 +1,91 @@
// Package gitea provides a client for the Gitea API. // Package gitea provides a client for the Gitea API.
// ipcheck.go re-exports the IsBlockedIP function from internal/netutil for use // ipcheck.go implements IP-level SSRF protection by checking resolved addresses
// by this package's safe dialer (client.go) and for backward compatibility with // against known blocked CIDR ranges (RFC1918, loopback, link-local, etc.).
// any callers that previously imported it from here.
//
// The implementation has moved to internal/netutil so it can be shared with the
// validate-url subcommand (cmd/review-bot/validateurl.go) without creating a
// dependency from VCS-generic code on the Gitea-specific package.
package gitea package gitea
import ( import (
"fmt"
"net" "net"
"gitea.weiker.me/rodin/review-bot/internal/netutil"
) )
// IsBlockedIP reports whether ip is in a blocked address range. // blockedCIDRStrings is the canonical list of CIDR strings that should never
// It delegates to internal/netutil.IsBlockedIP; see that function for the full // be contacted by review-bot. See IsBlockedIP for the full list of covered
// list of blocked ranges and IPv6-mapped IPv4 normalization behavior. // address families.
func IsBlockedIP(ip net.IP) bool { //
return netutil.IsBlockedIP(ip) // These are hard-coded literals: any parse failure is a programming error.
// Validity is verified by TestBlockedCIDRsValid in ipcheck_test.go.
var blockedCIDRStrings = []string{
// IPv4 loopback
"127.0.0.0/8",
// IPv4 unspecified / "this network"
"0.0.0.0/8",
// RFC1918 private ranges
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
// IPv4 link-local (APIPA, also used by AWS instance metadata 169.254.169.254)
"169.254.0.0/16",
// IPv4 shared address space (RFC6598, carrier-grade NAT)
"100.64.0.0/10",
// IPv4 multicast
"224.0.0.0/4",
// IPv4 reserved / broadcast
"240.0.0.0/4",
// IPv6 loopback
"::1/128",
// IPv6 unspecified
"::/128",
// IPv6 link-local
"fe80::/10",
// IPv6 unique local (ULA) — RFC4193
"fc00::/7",
// IPv6 multicast
"ff00::/8",
}
// blockedCIDRs is the parsed form of blockedCIDRStrings.
// Any entry that fails to parse is recorded in blockedCIDRParseErrors instead
// of panicking; tests verify this slice is always empty via TestBlockedCIDRsValid.
var (
blockedCIDRs []*net.IPNet
blockedCIDRParseErrors []string
)
func init() {
blockedCIDRs = make([]*net.IPNet, 0, len(blockedCIDRStrings))
for _, r := range blockedCIDRStrings {
_, cidr, err := net.ParseCIDR(r)
if err != nil {
// Record the error rather than panicking; TestBlockedCIDRsValid
// will catch this during tests, and the CI build will fail.
blockedCIDRParseErrors = append(blockedCIDRParseErrors,
fmt.Sprintf("ipcheck: invalid built-in CIDR %q: %v", r, err))
continue
}
blockedCIDRs = append(blockedCIDRs, cidr)
}
}
// IsBlockedIP reports whether ip is in a blocked address range.
// It is exported for use by the validate-url subcommand and tests outside
// this package.
//
// IPv6-mapped IPv4 addresses (e.g. ::ffff:192.168.1.1) are normalized to their
// IPv4 form before checking so that IPv4 CIDRs catch them.
//
// Based on:
// - RFC1918 private ranges
// - RFC5735 / RFC4193 special-use IPv4/IPv6 ranges
// - RFC4291 IPv6 link-local / loopback
func IsBlockedIP(ip net.IP) bool {
// Normalize IPv6-mapped IPv4 addresses (::ffff:x.x.x.x) to plain IPv4.
if v4 := ip.To4(); v4 != nil {
ip = v4
}
for _, cidr := range blockedCIDRs {
if cidr.Contains(ip) {
return true
}
}
return false
} }
+126 -19
View File
@@ -3,35 +3,142 @@ package gitea
import ( import (
"net" "net"
"testing" "testing"
"gitea.weiker.me/rodin/review-bot/internal/netutil"
) )
// TestIsBlockedIPForwarding verifies that gitea.IsBlockedIP correctly forwards func TestIsBlockedIP(t *testing.T) {
// to internal/netutil.IsBlockedIP. Full coverage of the blocking logic lives in blocked := []struct {
// internal/netutil/ipcheck_test.go. name string
func TestIsBlockedIPForwarding(t *testing.T) {
cases := []struct {
ip string ip string
blocked bool
}{ }{
{"127.0.0.1", true}, // loopback — must be blocked // IPv4 loopback
{"192.168.1.1", true}, // RFC1918 — must be blocked {"loopback 127.0.0.1", "127.0.0.1"},
{"8.8.8.8", false}, // public — must not be blocked {"loopback 127.0.0.2", "127.0.0.2"},
{"2001:4860:4860::8888", false}, // public IPv6 — must not be blocked {"loopback 127.255.255.255", "127.255.255.255"},
// IPv4 unspecified
{"unspecified 0.0.0.0", "0.0.0.0"},
{"unspecified 0.1.2.3", "0.1.2.3"},
// RFC1918
{"RFC1918 10.0.0.1", "10.0.0.1"},
{"RFC1918 10.255.255.255", "10.255.255.255"},
{"RFC1918 172.16.0.1", "172.16.0.1"},
{"RFC1918 172.31.255.255", "172.31.255.255"},
{"RFC1918 192.168.0.1", "192.168.0.1"},
{"RFC1918 192.168.255.255", "192.168.255.255"},
// Link-local (APIPA / AWS metadata)
{"link-local 169.254.0.1", "169.254.0.1"},
{"link-local 169.254.169.254", "169.254.169.254"},
// Shared address space (carrier-grade NAT)
{"CGN 100.64.0.1", "100.64.0.1"},
{"CGN 100.127.255.255", "100.127.255.255"},
// Multicast
{"multicast 224.0.0.1", "224.0.0.1"},
{"multicast 239.255.255.255", "239.255.255.255"},
// Reserved
{"reserved 240.0.0.1", "240.0.0.1"},
{"broadcast 255.255.255.255", "255.255.255.255"},
// IPv6 loopback
{"IPv6 loopback ::1", "::1"},
// IPv6 unspecified
{"IPv6 unspecified ::", "::"},
// IPv6 link-local
{"IPv6 link-local fe80::1", "fe80::1"},
{"IPv6 link-local fe80::dead:beef", "fe80::dead:beef"},
// IPv6 ULA
{"IPv6 ULA fc00::1", "fc00::1"},
{"IPv6 ULA fd00::1", "fd00::1"},
// IPv6 multicast
{"IPv6 multicast ff02::1", "ff02::1"},
} }
for _, tc := range cases {
for _, tc := range blocked {
t.Run(tc.name, func(t *testing.T) {
ip := net.ParseIP(tc.ip) ip := net.ParseIP(tc.ip)
if ip == nil { if ip == nil {
t.Fatalf("failed to parse IP %q", tc.ip) t.Fatalf("failed to parse IP %q", tc.ip)
} }
got := IsBlockedIP(ip) if !IsBlockedIP(ip) {
want := netutil.IsBlockedIP(ip) t.Errorf("IsBlockedIP(%q) = false, want true", tc.ip)
if got != want {
t.Errorf("gitea.IsBlockedIP(%q) = %v, netutil.IsBlockedIP = %v: forwarding mismatch", tc.ip, got, want)
} }
if got != tc.blocked { })
t.Errorf("gitea.IsBlockedIP(%q) = %v, want %v", tc.ip, got, tc.blocked) }
allowed := []struct {
name string
ip string
}{
{"public 8.8.8.8", "8.8.8.8"},
{"public 1.1.1.1", "1.1.1.1"},
{"public 198.51.100.1", "198.51.100.1"}, // RFC5737 TEST-NET-2 — a documentation-only range;
// not assigned to any real host, but intentionally left unblocked here because
// it has no special routing treatment (unlike RFC1918/loopback/link-local) and
// blocking it would require tracking every RFC5737 range without meaningful
// security benefit (no server should ever listen on a TEST-NET address).
{"public 151.101.1.1", "151.101.1.1"}, // Fastly
{"public IPv6 2001:4860:4860::8888", "2001:4860:4860::8888"}, // Google DNS
{"public IPv6 2606:4700:4700::1111", "2606:4700:4700::1111"}, // Cloudflare DNS
}
for _, tc := range allowed {
t.Run(tc.name, func(t *testing.T) {
ip := net.ParseIP(tc.ip)
if ip == nil {
t.Fatalf("failed to parse IP %q", tc.ip)
}
if IsBlockedIP(ip) {
t.Errorf("IsBlockedIP(%q) = true, want false", tc.ip)
}
})
}
}
func TestIsBlockedIPv6MappedIPv4(t *testing.T) {
// ::ffff:192.168.1.1 is an IPv6-mapped IPv4 address — should be blocked as RFC1918.
// Construct it manually as a 16-byte IP.
mapped := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 192, 168, 1, 1}
if !IsBlockedIP(mapped) {
t.Errorf("IsBlockedIP(::ffff:192.168.1.1) = false, want true (IPv6-mapped IPv4 must be normalized)")
}
// ::ffff:8.8.8.8 — IPv6-mapped public IP — should be allowed.
mappedPublic := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 8, 8, 8, 8}
if IsBlockedIP(mappedPublic) {
t.Errorf("IsBlockedIP(::ffff:8.8.8.8) = true, want false")
}
}
func TestIsBlockedIPEdgeCases(t *testing.T) {
// The boundary between RFC1918 and public ranges.
// 172.15.255.255 is NOT private (just below 172.16.0.0/12).
notPrivate := net.ParseIP("172.15.255.255")
if IsBlockedIP(notPrivate) {
t.Errorf("IsBlockedIP(172.15.255.255) = true, want false (outside 172.16.0.0/12)")
}
// 172.32.0.0 is NOT private (just above 172.31.255.255).
notPrivate2 := net.ParseIP("172.32.0.0")
if IsBlockedIP(notPrivate2) {
t.Errorf("IsBlockedIP(172.32.0.0) = true, want false (outside 172.16.0.0/12)")
}
// CGN: 100.63.255.255 is NOT in 100.64.0.0/10.
notCGN := net.ParseIP("100.63.255.255")
if IsBlockedIP(notCGN) {
t.Errorf("IsBlockedIP(100.63.255.255) = true, want false (outside 100.64.0.0/10)")
}
// CGN: 100.128.0.0 is NOT in 100.64.0.0/10.
notCGN2 := net.ParseIP("100.128.0.0")
if IsBlockedIP(notCGN2) {
t.Errorf("IsBlockedIP(100.128.0.0) = true, want false (outside 100.64.0.0/10)")
}
}
// TestBlockedCIDRsValid verifies that all entries in blockedCIDRStrings parse
// successfully. This catches programming errors in the CIDR list without
// requiring a startup panic. The init() function records parse failures in
// blockedCIDRParseErrors rather than panicking; this test makes those failures
// visible as test failures during CI.
func TestBlockedCIDRsValid(t *testing.T) {
if len(blockedCIDRParseErrors) > 0 {
for _, msg := range blockedCIDRParseErrors {
t.Errorf("CIDR parse error: %s", msg)
} }
} }
} }
-97
View File
@@ -1,97 +0,0 @@
// Package netutil provides shared network utilities for review-bot.
// ipcheck.go implements IP-level SSRF protection by checking resolved addresses
// against known blocked CIDR ranges (RFC1918, loopback, link-local, etc.).
package netutil
import (
"fmt"
"net"
)
// blockedCIDRStrings is the canonical list of CIDR strings that should never
// be contacted by review-bot. See IsBlockedIP for the full list of covered
// address families.
//
// These are hard-coded literals: any parse failure is a programming error.
// Validity is verified by TestBlockedCIDRsValid in ipcheck_test.go.
var blockedCIDRStrings = []string{
// IPv4 loopback
"127.0.0.0/8",
// IPv4 unspecified / "this network"
"0.0.0.0/8",
// RFC1918 private ranges
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
// IPv4 link-local (APIPA, also used by AWS instance metadata 169.254.169.254)
"169.254.0.0/16",
// IPv4 shared address space (RFC6598, carrier-grade NAT)
"100.64.0.0/10",
// IPv4 multicast
"224.0.0.0/4",
// IPv4 reserved / broadcast
"240.0.0.0/4",
// IPv6 loopback
"::1/128",
// IPv6 unspecified
"::/128",
// IPv6 link-local
"fe80::/10",
// IPv6 unique local (ULA) — RFC4193
"fc00::/7",
// IPv6 multicast
"ff00::/8",
}
// blockedCIDRs is the parsed form of blockedCIDRStrings.
// Any entry that fails to parse is recorded in blockedCIDRParseErrors instead
// of panicking; tests verify this slice is always empty via TestBlockedCIDRsValid.
var (
blockedCIDRs []*net.IPNet
blockedCIDRParseErrors []string
)
func init() {
blockedCIDRs = make([]*net.IPNet, 0, len(blockedCIDRStrings))
for _, r := range blockedCIDRStrings {
_, cidr, err := net.ParseCIDR(r)
if err != nil {
// Record the error rather than panicking; TestBlockedCIDRsValid
// will catch this during tests, and the CI build will fail.
blockedCIDRParseErrors = append(blockedCIDRParseErrors,
fmt.Sprintf("ipcheck: invalid built-in CIDR %q: %v", r, err))
continue
}
blockedCIDRs = append(blockedCIDRs, cidr)
}
}
// BlockedCIDRParseErrors returns any errors encountered parsing the built-in
// CIDR list. In correct code this will always be empty; tests assert it is.
func BlockedCIDRParseErrors() []string {
return blockedCIDRParseErrors
}
// IsBlockedIP reports whether ip is in a blocked address range.
// It is exported for use by the gitea package's safe dialer, the validate-url
// subcommand, and tests outside this package.
//
// IPv6-mapped IPv4 addresses (e.g. ::ffff:192.168.1.1) are normalized to their
// IPv4 form before checking so that IPv4 CIDRs catch them.
//
// Based on:
// - RFC1918 private ranges
// - RFC5735 / RFC4193 special-use IPv4/IPv6 ranges
// - RFC4291 IPv6 link-local / loopback
func IsBlockedIP(ip net.IP) bool {
// Normalize IPv6-mapped IPv4 addresses (::ffff:x.x.x.x) to plain IPv4.
if v4 := ip.To4(); v4 != nil {
ip = v4
}
for _, cidr := range blockedCIDRs {
if cidr.Contains(ip) {
return true
}
}
return false
}
-142
View File
@@ -1,142 +0,0 @@
package netutil
import (
"net"
"testing"
)
func TestIsBlockedIP(t *testing.T) {
blocked := []struct {
name string
ip string
}{
// IPv4 loopback
{"loopback 127.0.0.1", "127.0.0.1"},
{"loopback 127.0.0.2", "127.0.0.2"},
{"loopback 127.255.255.255", "127.255.255.255"},
// IPv4 unspecified
{"unspecified 0.0.0.0", "0.0.0.0"},
{"unspecified 0.1.2.3", "0.1.2.3"},
// RFC1918
{"RFC1918 10.0.0.1", "10.0.0.1"},
{"RFC1918 10.255.255.255", "10.255.255.255"},
{"RFC1918 172.16.0.1", "172.16.0.1"},
{"RFC1918 172.31.255.255", "172.31.255.255"},
{"RFC1918 192.168.0.1", "192.168.0.1"},
{"RFC1918 192.168.255.255", "192.168.255.255"},
// Link-local (APIPA / AWS metadata)
{"link-local 169.254.0.1", "169.254.0.1"},
{"link-local 169.254.169.254", "169.254.169.254"},
// Shared address space (carrier-grade NAT)
{"CGN 100.64.0.1", "100.64.0.1"},
{"CGN 100.127.255.255", "100.127.255.255"},
// Multicast
{"multicast 224.0.0.1", "224.0.0.1"},
{"multicast 239.255.255.255", "239.255.255.255"},
// Reserved
{"reserved 240.0.0.1", "240.0.0.1"},
{"broadcast 255.255.255.255", "255.255.255.255"},
// IPv6 loopback
{"IPv6 loopback ::1", "::1"},
// IPv6 unspecified
{"IPv6 unspecified ::", "::"},
// IPv6 link-local
{"IPv6 link-local fe80::1", "fe80::1"},
{"IPv6 link-local fe80::dead:beef", "fe80::dead:beef"},
// IPv6 ULA
{"IPv6 ULA fc00::1", "fc00::1"},
{"IPv6 ULA fd00::1", "fd00::1"},
// IPv6 multicast
{"IPv6 multicast ff02::1", "ff02::1"},
}
for _, tc := range blocked {
t.Run(tc.name, func(t *testing.T) {
ip := net.ParseIP(tc.ip)
if ip == nil {
t.Fatalf("failed to parse IP %q", tc.ip)
}
if !IsBlockedIP(ip) {
t.Errorf("IsBlockedIP(%q) = false, want true", tc.ip)
}
})
}
allowed := []struct {
name string
ip string
}{
{"public 8.8.8.8", "8.8.8.8"},
{"public 1.1.1.1", "1.1.1.1"},
{"public 198.51.100.1", "198.51.100.1"}, // RFC5737 TEST-NET-2 — a documentation-only range;
// not assigned to any real host, but intentionally left unblocked here because
// it has no special routing treatment (unlike RFC1918/loopback/link-local) and
// blocking it would require tracking every RFC5737 range without meaningful
// security benefit (no server should ever listen on a TEST-NET address).
{"public 151.101.1.1", "151.101.1.1"}, // Fastly
{"public IPv6 2001:4860:4860::8888", "2001:4860:4860::8888"}, // Google DNS
{"public IPv6 2606:4700:4700::1111", "2606:4700:4700::1111"}, // Cloudflare DNS
}
for _, tc := range allowed {
t.Run(tc.name, func(t *testing.T) {
ip := net.ParseIP(tc.ip)
if ip == nil {
t.Fatalf("failed to parse IP %q", tc.ip)
}
if IsBlockedIP(ip) {
t.Errorf("IsBlockedIP(%q) = true, want false", tc.ip)
}
})
}
}
func TestIsBlockedIPv6MappedIPv4(t *testing.T) {
// ::ffff:192.168.1.1 is an IPv6-mapped IPv4 address — should be blocked as RFC1918.
// Construct it manually as a 16-byte IP.
mapped := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 192, 168, 1, 1}
if !IsBlockedIP(mapped) {
t.Errorf("IsBlockedIP(::ffff:192.168.1.1) = false, want true (IPv6-mapped IPv4 must be normalized)")
}
// ::ffff:8.8.8.8 — IPv6-mapped public IP — should be allowed.
mappedPublic := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 8, 8, 8, 8}
if IsBlockedIP(mappedPublic) {
t.Errorf("IsBlockedIP(::ffff:8.8.8.8) = true, want false")
}
}
func TestIsBlockedIPEdgeCases(t *testing.T) {
// The boundary between RFC1918 and public ranges.
// 172.15.255.255 is NOT private (just below 172.16.0.0/12).
notPrivate := net.ParseIP("172.15.255.255")
if IsBlockedIP(notPrivate) {
t.Errorf("IsBlockedIP(172.15.255.255) = true, want false (outside 172.16.0.0/12)")
}
// 172.32.0.0 is NOT private (just above 172.31.255.255).
notPrivate2 := net.ParseIP("172.32.0.0")
if IsBlockedIP(notPrivate2) {
t.Errorf("IsBlockedIP(172.32.0.0) = true, want false (outside 172.16.0.0/12)")
}
// CGN: 100.63.255.255 is NOT in 100.64.0.0/10.
notCGN := net.ParseIP("100.63.255.255")
if IsBlockedIP(notCGN) {
t.Errorf("IsBlockedIP(100.63.255.255) = true, want false (outside 100.64.0.0/10)")
}
// CGN: 100.128.0.0 is NOT in 100.64.0.0/10.
notCGN2 := net.ParseIP("100.128.0.0")
if IsBlockedIP(notCGN2) {
t.Errorf("IsBlockedIP(100.128.0.0) = true, want false (outside 100.64.0.0/10)")
}
}
// TestBlockedCIDRsValid verifies that all entries in blockedCIDRStrings parse
// successfully. This catches programming errors in the CIDR list without
// requiring a startup panic. The init() function records parse failures in
// blockedCIDRParseErrors rather than panicking; this test makes those failures
// visible as test failures during CI.
func TestBlockedCIDRsValid(t *testing.T) {
for _, msg := range BlockedCIDRParseErrors() {
t.Errorf("CIDR parse error: %s", msg)
}
}