registry/internal/api/handlers/v0/auth/http_internal_test.go
Radoslav Dimitrov 1201cbd82b
security: fix open redirect and add small hardening (#1227)
## What this does

Fixes an open redirect plus a handful of small, mostly-invisible
hardening changes around it.

## What's in it

- **The fix**: trailing-slash redirects no longer let `//evil.com/`
redirect off-host.
- **SSRF block** on the DNS/HTTP namespace verification — refuses to
dial loopback, RFC1918, link-local, multicast, CGNAT, or
IP-literal/single-label \"domains\".
- **Slow-client guard**: `ReadTimeout` and `IdleTimeout` on the HTTP
server (no `WriteTimeout` — would risk cutting off legitimate
multi-package publishes).
- **Defence-in-depth headers** on the UI (`/`) and 404 responses.
- **5xx responses** stop leaking raw DB error text to clients (logged
server-side instead).
- **Search filter** stops treating `%` and `_` as wildcards.
- **Validators**: PyPI/NuGet identifiers URL-escaped before fetching;
MCPB no longer follows redirects.
- **Misc**: `Version` field bounded to 255 chars; logs quote user input;
`BlockedNamespaces` denylist now catches subdomain claimants.

Each change is its own commit so they can be reviewed (or reverted)
independently.

## Test plan

- [ ] CI green
- [ ] Synthetic publish in staging for each package type (npm, pypi,
nuget, oci, mcpb) — confirms validator changes don't regress
- [ ] Quick browser check of `/` after deploy to confirm UI still loads
with the new CSP
- [ ] `curl https://<host>//evil.com/` — confirm the open redirect is
closed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:14:00 -04:00

52 lines
1.2 KiB
Go

package auth
import (
"net"
"testing"
)
func TestIsBlockedIP(t *testing.T) {
tests := []struct {
ip string
blocked bool
}{
// Blocked — loopback
{"127.0.0.1", true},
{"::1", true},
// Blocked — RFC1918 / ULA (IsPrivate)
{"10.0.0.1", true},
{"172.16.0.1", true},
{"192.168.1.1", true},
{"fc00::1", true},
// Blocked — link-local (includes cloud metadata 169.254.169.254)
{"169.254.169.254", true},
{"fe80::1", true},
// Blocked — unspecified
{"0.0.0.0", true},
{"::", true},
// Blocked — admin-scoped and broader multicast
{"239.0.0.1", true},
{"ff00::1", true},
// Blocked — Carrier-Grade NAT (RFC 6598)
{"100.64.0.1", true},
{"100.127.255.254", true},
// Allowed — public
{"1.1.1.1", false},
{"8.8.8.8", false},
{"2606:4700:4700::1111", false},
// Allowed — outside CGNAT range
{"100.63.255.255", false},
{"100.128.0.1", false},
}
for _, tc := range tests {
t.Run(tc.ip, func(t *testing.T) {
ip := net.ParseIP(tc.ip)
if ip == nil {
t.Fatalf("ParseIP(%q) returned nil", tc.ip)
}
if got := isBlockedIP(ip); got != tc.blocked {
t.Errorf("isBlockedIP(%q) = %v, want %v", tc.ip, got, tc.blocked)
}
})
}
}