mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
The client held the Klipy key in VITE_KLIPY_API_KEY, which Vite inlines into
the shipped bundle by design — a build variable can never hold a secret. Move
the integration behind the server:
- New authenticated GET /api/v1/gif/search and /api/v1/gif/trending. The key
comes from the new `gif.api_key` config section (koanf,
OWNCORD_GIF_API_KEY) and never leaves the server.
- Default-off: with no key, both endpoints return 503 GIF_DISABLED so clients
can hide the picker instead of showing a broken one. Auth is checked first,
so anonymous callers cannot probe whether a key is configured.
- Outbound call reuses the existing SSRF-guarded dialer (exported as
plugin.GuardedDialContext) rather than a bare http.Get: resolve once,
reject private/loopback/link-local/CGN, dial only vetted IPs. Redirects are
not followed and the response body is size-capped.
- Only id/title/media_formats.{tinygif,gif}.url are forwarded — decoding into
the narrow struct is the allowlist, so an upstream that echoed the key
could not leak it. Upstream errors become a generic 502 and the key is
redacted from anything that reaches the logs.
- Dedicated `gif:` rate-limit bucket (30/min per IP) so debounced search
traffic cannot exhaust the shared bucket used by password/TOTP endpoints.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
188 lines
5.5 KiB
Go
188 lines
5.5 KiB
Go
// Pass 4 — host HTTP allowlist + IP guard tests.
|
|
//
|
|
// Locks in the SSRF defenses added in Pass 2 (dot-bounded host suffix
|
|
// matching, empty-entry rejection) and Pass 3 (RFC6598 CGN rejection).
|
|
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"testing"
|
|
)
|
|
|
|
func newTestRegistry(allowlist []string) *Registry {
|
|
return &Registry{cfg: Config{HTTPAllowlist: allowlist}}
|
|
}
|
|
|
|
func TestHostAllowedDotBoundary(t *testing.T) {
|
|
r := newTestRegistry([]string{"api.example.com", "example.org"})
|
|
cases := []struct {
|
|
host string
|
|
ok bool
|
|
}{
|
|
{"api.example.com", true},
|
|
{"v1.api.example.com", true},
|
|
{"example.org", true},
|
|
{"sub.example.org", true},
|
|
// Sibling-domain attack — must NOT match.
|
|
{"evil-api.example.com", false},
|
|
{"notexample.com", false},
|
|
{"example.com", false}, // not in list
|
|
{"evil.com", false},
|
|
{"", false},
|
|
{"api.example.com.evil.com", false},
|
|
}
|
|
for _, c := range cases {
|
|
got := r.hostAllowed(c.host)
|
|
if got != c.ok {
|
|
t.Errorf("hostAllowed(%q) = %v, want %v", c.host, got, c.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHostAllowedEmptyEntryRejected(t *testing.T) {
|
|
r := newTestRegistry([]string{""})
|
|
if r.hostAllowed("anything.com") {
|
|
t.Fatal("empty allowlist entry must NOT wildcard-match")
|
|
}
|
|
if r.hostAllowed("") {
|
|
t.Fatal("empty host must not match empty entry")
|
|
}
|
|
}
|
|
|
|
func TestHostAllowedCaseInsensitive(t *testing.T) {
|
|
r := newTestRegistry([]string{"API.Example.COM"})
|
|
if !r.hostAllowed("api.example.com") {
|
|
t.Fatal("hostAllowed should be case-insensitive")
|
|
}
|
|
if !r.hostAllowed("API.example.com") {
|
|
t.Fatal("hostAllowed should be case-insensitive")
|
|
}
|
|
}
|
|
|
|
func TestHostAllowedTrailingDot(t *testing.T) {
|
|
r := newTestRegistry([]string{"api.example.com"})
|
|
if !r.hostAllowed("api.example.com.") {
|
|
t.Fatal("FQDN trailing dot should match")
|
|
}
|
|
}
|
|
|
|
func TestIPAllowedRejectsAllRanges(t *testing.T) {
|
|
cases := []string{
|
|
"127.0.0.1", // loopback
|
|
"127.5.6.7", // loopback range
|
|
"10.0.0.1", // RFC1918
|
|
"172.16.5.5", // RFC1918
|
|
"172.31.255.255", // RFC1918 high
|
|
"192.168.1.1", // RFC1918
|
|
"169.254.169.254", // AWS metadata / link-local
|
|
"100.64.5.5", // RFC6598 CGN
|
|
"100.127.255.255", // RFC6598 CGN high
|
|
"::1", // IPv6 loopback
|
|
"fc00::1", // RFC4193 ULA
|
|
"fe80::1", // IPv6 link-local
|
|
"0.0.0.0", // unspecified
|
|
"::", // IPv6 unspecified
|
|
"224.0.0.1", // multicast
|
|
"ff00::1", // IPv6 multicast
|
|
}
|
|
for _, addr := range cases {
|
|
ip := net.ParseIP(addr)
|
|
if ip == nil {
|
|
t.Fatalf("ParseIP(%q) failed", addr)
|
|
}
|
|
if err := ipAllowed(ip); err == nil {
|
|
t.Errorf("ipAllowed(%s) should have returned error", addr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIPAllowedAcceptsPublic(t *testing.T) {
|
|
cases := []string{
|
|
"8.8.8.8",
|
|
"1.1.1.1",
|
|
"203.0.113.5", // RFC5737 documentation but not in any reject set
|
|
"2606:4700:4700::1111",
|
|
}
|
|
for _, addr := range cases {
|
|
ip := net.ParseIP(addr)
|
|
if ip == nil {
|
|
t.Fatalf("ParseIP(%q) failed", addr)
|
|
}
|
|
if err := ipAllowed(ip); err != nil {
|
|
t.Errorf("ipAllowed(%s) should have been allowed, got %v", addr, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGuardedDial_FallsBackAcrossVettedIPs locks the W2-6 fix: an allowlisted
|
|
// dual-stack/round-robin host whose first record is unreachable must connect
|
|
// via the next vetted record instead of hard-failing.
|
|
func TestGuardedDial_FallsBackAcrossVettedIPs(t *testing.T) {
|
|
origLookup, origDial := lookupIPAddr, dialContext
|
|
t.Cleanup(func() { lookupIPAddr, dialContext = origLookup, origDial })
|
|
|
|
lookupIPAddr = func(_ context.Context, _ string) ([]net.IPAddr, error) {
|
|
return []net.IPAddr{
|
|
{IP: net.ParseIP("192.0.2.1")}, // TEST-NET, "down"
|
|
{IP: net.ParseIP("192.0.2.2")}, // "reachable"
|
|
}, nil
|
|
}
|
|
var attempts []string
|
|
c1, c2 := net.Pipe()
|
|
t.Cleanup(func() { _ = c1.Close(); _ = c2.Close() })
|
|
dialContext = func(_ context.Context, _ string, addr string) (net.Conn, error) {
|
|
attempts = append(attempts, addr)
|
|
if addr == "192.0.2.1:443" {
|
|
return nil, errors.New("connection refused")
|
|
}
|
|
return c1, nil
|
|
}
|
|
|
|
conn, err := GuardedDialContext()(context.Background(), "tcp", "api.example.com:443")
|
|
if err != nil {
|
|
t.Fatalf("guarded dial should fall back to the next vetted IP: %v", err)
|
|
}
|
|
if conn != c1 {
|
|
t.Fatal("expected the fallback connection")
|
|
}
|
|
want := []string{"192.0.2.1:443", "192.0.2.2:443"}
|
|
if len(attempts) != 2 || attempts[0] != want[0] || attempts[1] != want[1] {
|
|
t.Fatalf("dial attempts = %v, want %v", attempts, want)
|
|
}
|
|
}
|
|
|
|
// TestGuardedDial_PrivateRecordRefusesBeforeAnyDial: one private record among
|
|
// the resolved set refuses the whole request before a single dial happens.
|
|
func TestGuardedDial_PrivateRecordRefusesBeforeAnyDial(t *testing.T) {
|
|
origLookup, origDial := lookupIPAddr, dialContext
|
|
t.Cleanup(func() { lookupIPAddr, dialContext = origLookup, origDial })
|
|
|
|
lookupIPAddr = func(_ context.Context, _ string) ([]net.IPAddr, error) {
|
|
return []net.IPAddr{
|
|
{IP: net.ParseIP("192.0.2.1")},
|
|
{IP: net.ParseIP("10.0.0.5")}, // poisoned private record
|
|
}, nil
|
|
}
|
|
dialed := false
|
|
dialContext = func(_ context.Context, _ string, _ string) (net.Conn, error) {
|
|
dialed = true
|
|
return nil, errors.New("must not be reached")
|
|
}
|
|
|
|
_, err := GuardedDialContext()(context.Background(), "tcp", "api.example.com:443")
|
|
if !errors.Is(err, ErrHTTPHostDenied) {
|
|
t.Fatalf("want ErrHTTPHostDenied, got %v", err)
|
|
}
|
|
if dialed {
|
|
t.Fatal("no dial may happen when any resolved record is private")
|
|
}
|
|
}
|
|
|
|
func TestIPAllowedNilRejected(t *testing.T) {
|
|
if err := ipAllowed(nil); err == nil {
|
|
t.Fatal("nil IP should be rejected")
|
|
}
|
|
}
|