WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
// Package ipclass categorises IP addresses for outbound-request policy.
|
||||
//
|
||||
// The classification lives here, separate from the policy, because WeKnora
|
||||
// guards two kinds of outbound target whose threat models differ:
|
||||
//
|
||||
// - internal/utils guards URLs an end user submits, so it refuses every
|
||||
// class except Public.
|
||||
// - internal/sandbox guards cluster endpoints a workspace admin configures,
|
||||
// so it has to tell "never routable" apart from "private, which this
|
||||
// workspace may have opted into".
|
||||
//
|
||||
// Sharing the classification while keeping the policy at each call site is
|
||||
// what stops the two from drifting. The drift is not hypothetical: net.IP's
|
||||
// IsPrivate covers fc00::/7 but not fec0::/10, and neither it nor
|
||||
// IsLinkLocalUnicast looks inside a 6to4, Teredo, NAT64 or IPv4-compatible
|
||||
// address, so a hand-rolled "is this private" check silently admits several
|
||||
// spellings of 169.254.169.254.
|
||||
package ipclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// Class is the policy-relevant category of an address. This package decides
|
||||
// which class an address is; callers decide which classes they refuse.
|
||||
type Class int
|
||||
|
||||
const (
|
||||
// Public is globally routable with no policy objection.
|
||||
Public Class = iota
|
||||
|
||||
// Invalid is a nil or malformed address. It is its own class rather than
|
||||
// Public so that a caller which forgets to check fails closed.
|
||||
Invalid
|
||||
|
||||
// Unspecified is 0.0.0.0 or ::.
|
||||
Unspecified
|
||||
|
||||
// Loopback is 127.0.0.0/8 or ::1.
|
||||
Loopback
|
||||
|
||||
// Private is RFC1918 or an IPv6 unique local address (fc00::/7).
|
||||
Private
|
||||
|
||||
// CGNAT is 100.64.0.0/10 (RFC 6598). IsPrivate does not cover it, but it
|
||||
// is just as unsuitable as a public endpoint.
|
||||
CGNAT
|
||||
|
||||
// LinkLocal is 169.254.0.0/16 or fe80::/10, plus link-local multicast.
|
||||
// This is the class that carries cloud metadata services, and the one
|
||||
// callers should refuse regardless of any private-network opt-in.
|
||||
LinkLocal
|
||||
|
||||
// Multicast is any other multicast address, interface-local included.
|
||||
Multicast
|
||||
|
||||
// SiteLocalIPv6 is the deprecated fec0::/10. Distinct from Private
|
||||
// because IsPrivate does not cover it.
|
||||
SiteLocalIPv6
|
||||
|
||||
// Reserved is a range that cannot reach a real service: 0.0.0.0/8,
|
||||
// 240.0.0.0/4 (broadcast included), and the IETF assignment and
|
||||
// benchmarking ranges.
|
||||
Reserved
|
||||
|
||||
// Documentation is TEST-NET-1/2/3. Reserved for documentation and never
|
||||
// routed, so refusing it buys no security. It is a separate class so a
|
||||
// caller can accept it as a DNS-free stand-in for a public address.
|
||||
Documentation
|
||||
|
||||
// Translated is an IPv6 address carrying a restricted IPv4 address: 6to4,
|
||||
// Teredo, NAT64's well-known prefix, or the deprecated IPv4-compatible
|
||||
// form. These are how a target reaches link-local while the outer address
|
||||
// still looks like ordinary public IPv6.
|
||||
Translated
|
||||
)
|
||||
|
||||
// ipv4Range pairs a CIDR with the class addresses inside it belong to.
|
||||
type ipv4Range struct {
|
||||
net *net.IPNet
|
||||
class Class
|
||||
}
|
||||
|
||||
// restrictedIPv4Ranges covers the IPv4 blocks that net.IP's own predicates
|
||||
// miss. The entries are disjoint, so the iteration order does not affect the
|
||||
// verdict.
|
||||
//
|
||||
// Two ranges are deliberately absent because a wider entry already covers
|
||||
// them: 255.255.255.255 sits in 240.0.0.0/4, and Docker's default bridge
|
||||
// networks (172.17.0.0/16 and neighbours) sit in 172.16.0.0/12, which
|
||||
// IsPrivate classifies as Private before this table is consulted.
|
||||
var restrictedIPv4Ranges = []ipv4Range{
|
||||
{mustCIDR("100.64.0.0/10"), CGNAT}, // RFC 6598 carrier-grade NAT
|
||||
{mustCIDR("0.0.0.0/8"), Reserved}, // RFC 1122 "this" network
|
||||
{mustCIDR("240.0.0.0/4"), Reserved}, // RFC 1112 reserved, incl. broadcast
|
||||
{mustCIDR("198.18.0.0/15"), Reserved}, // RFC 2544 benchmarking
|
||||
{mustCIDR("192.0.0.0/24"), Reserved}, // RFC 6890 IETF assignments
|
||||
{mustCIDR("192.0.2.0/24"), Documentation}, // TEST-NET-1
|
||||
{mustCIDR("198.51.100.0/24"), Documentation}, // TEST-NET-2
|
||||
{mustCIDR("203.0.113.0/24"), Documentation}, // TEST-NET-3
|
||||
}
|
||||
|
||||
func mustCIDR(s string) *net.IPNet {
|
||||
_, ipNet, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("ipclass: invalid CIDR %s: %v", s, err))
|
||||
}
|
||||
return ipNet
|
||||
}
|
||||
|
||||
// Classify categorises ip and returns a human-readable reason naming the
|
||||
// class. The reason is empty for Public.
|
||||
func Classify(ip net.IP) (Class, string) {
|
||||
// A net.IP is only meaningful at 4 or 16 bytes. Anything else — nil, or a
|
||||
// slice built by hand — must not fall through to Public, because callers
|
||||
// read Public as permission to dial.
|
||||
if len(ip) != net.IPv4len && len(ip) != net.IPv6len {
|
||||
return Invalid, "invalid address"
|
||||
}
|
||||
|
||||
// net.IP's predicates run first because they normalise IPv4-in-IPv6
|
||||
// through To4(); that is what catches ::ffff:169.254.169.254 here as
|
||||
// link-local instead of leaving it to the IPv6 cases below.
|
||||
switch {
|
||||
case ip.IsPrivate():
|
||||
return Private, "private IP address"
|
||||
case ip.IsLoopback():
|
||||
return Loopback, "loopback address"
|
||||
case ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast():
|
||||
return LinkLocal, "link-local address"
|
||||
case ip.IsMulticast():
|
||||
return Multicast, "multicast address"
|
||||
case ip.IsUnspecified():
|
||||
return Unspecified, "unspecified address"
|
||||
}
|
||||
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
for _, r := range restrictedIPv4Ranges {
|
||||
if r.net.Contains(ip4) {
|
||||
return r.class, fmt.Sprintf("restricted range %s", r.net.String())
|
||||
}
|
||||
}
|
||||
return Public, ""
|
||||
}
|
||||
|
||||
if len(ip) == net.IPv6len {
|
||||
return classifyIPv6(ip)
|
||||
}
|
||||
return Public, ""
|
||||
}
|
||||
|
||||
// IsPublic reports whether ip carries no policy objection at all. Callers that
|
||||
// need to distinguish "private, maybe allowed" from "never routable" should use
|
||||
// Classify instead.
|
||||
func IsPublic(ip net.IP) bool {
|
||||
class, _ := Classify(ip)
|
||||
return class == Public
|
||||
}
|
||||
|
||||
func classifyIPv6(ip net.IP) (Class, string) {
|
||||
// fec0::/10, deprecated site-local. IsPrivate stops at fc00::/7.
|
||||
if ip[0] == 0xfe && ip[1]&0xc0 == 0xc0 {
|
||||
return SiteLocalIPv6, "site-local IPv6 address"
|
||||
}
|
||||
// fc00::/7 unique local. IsPrivate already caught this; kept so the
|
||||
// function is correct when read on its own.
|
||||
if ip[0]&0xfe == 0xfc {
|
||||
return Private, "unique local IPv6 address"
|
||||
}
|
||||
// Teredo, 2001::/32. Refused outright rather than by embedded address:
|
||||
// the payload is obfuscated, and the tunnel exists to reach elsewhere.
|
||||
if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 {
|
||||
return Translated, "Teredo tunneling address"
|
||||
}
|
||||
// The remaining encodings carry a plain IPv4 address, so the payload
|
||||
// decides: 6to4 wrapping a public address is a public address.
|
||||
if embedded, encoding := embeddedIPv4(ip); embedded != nil {
|
||||
if class, reason := Classify(embedded); class != Public {
|
||||
return Translated, fmt.Sprintf("%s %s", encoding, reason)
|
||||
}
|
||||
}
|
||||
return Public, ""
|
||||
}
|
||||
|
||||
// embeddedIPv4 returns the IPv4 address an IPv6 address carries, for the
|
||||
// encodings that can reach an IPv4 destination through a relay or translator,
|
||||
// along with a name for the encoding.
|
||||
func embeddedIPv4(ip net.IP) (net.IP, string) {
|
||||
switch {
|
||||
// 6to4, 2002::/16: bits 16-47 hold the IPv4 address.
|
||||
case ip[0] == 0x20 && ip[1] == 0x02:
|
||||
return net.IP(ip[2:6]), "6to4 embedded"
|
||||
// NAT64 well-known prefix, 64:ff9b::/96 (RFC 6052). Unlike 6to4 this one
|
||||
// is in live use, so an operator running NAT64 makes every internal IPv4
|
||||
// address reachable through it.
|
||||
case ip[0] == 0x00 && ip[1] == 0x64 && ip[2] == 0xff && ip[3] == 0x9b &&
|
||||
isZeros(ip[4:12]):
|
||||
return net.IP(ip[12:16]), "NAT64 embedded"
|
||||
// IPv4-compatible ::a.b.c.d, deprecated by RFC 4291. ::ffff:a.b.c.d never
|
||||
// reaches here (To4 handles it), and :: and ::1 are already classified.
|
||||
case isZeros(ip[0:12]):
|
||||
return net.IP(ip[12:16]), "IPv4-compatible"
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func isZeros(b []byte) bool {
|
||||
for _, v := range b {
|
||||
if v != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package ipclass
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassify(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ip string
|
||||
class Class
|
||||
}{
|
||||
{"IPv4 public", "8.8.8.8", Public},
|
||||
{"IPv6 public", "2001:4860:4860::8888", Public},
|
||||
{"IPv4 loopback", "127.0.0.1", Loopback},
|
||||
{"IPv6 loopback", "::1", Loopback},
|
||||
{"RFC1918 ten", "10.0.0.5", Private},
|
||||
{"RFC1918 172", "172.16.3.4", Private},
|
||||
{"RFC1918 192", "192.168.1.1", Private},
|
||||
{"docker bridge is private", "172.17.0.1", Private},
|
||||
{"IPv6 ULA", "fd12:3456:789a::1", Private},
|
||||
{"carrier-grade NAT", "100.64.0.1", CGNAT},
|
||||
{"cloud metadata", "169.254.169.254", LinkLocal},
|
||||
{"IPv6 link-local", "fe80::1", LinkLocal},
|
||||
{"unspecified v4", "0.0.0.0", Unspecified},
|
||||
{"unspecified v6", "::", Unspecified},
|
||||
{"multicast", "239.1.2.3", Multicast},
|
||||
{"interface-local multicast", "ff01::1", Multicast},
|
||||
{"broadcast", "255.255.255.255", Reserved},
|
||||
{"reserved 240/4", "240.0.0.1", Reserved},
|
||||
{"benchmarking", "198.18.0.1", Reserved},
|
||||
{"IETF assignments", "192.0.0.1", Reserved},
|
||||
{"TEST-NET-1", "192.0.2.1", Documentation},
|
||||
{"TEST-NET-2", "198.51.100.1", Documentation},
|
||||
{"TEST-NET-3", "203.0.113.10", Documentation},
|
||||
{"IPv6 documentation prefix is not special", "2001:db8::1", Public},
|
||||
|
||||
// net.IP's predicates normalise this one through To4(), which is why
|
||||
// it lands on LinkLocal rather than needing its own encoding case.
|
||||
{"IPv4-mapped metadata", "::ffff:169.254.169.254", LinkLocal},
|
||||
|
||||
// The encodings that a guard looking only at the outer address family
|
||||
// would wave through. Each of these spells 169.254.169.254.
|
||||
{"6to4 metadata", "2002:a9fe:a9fe::1", Translated},
|
||||
{"NAT64 metadata", "64:ff9b::a9fe:a9fe", Translated},
|
||||
{"IPv4-compatible metadata", "::169.254.169.254", Translated},
|
||||
{"6to4 private payload", "2002:c0a8:0101::1", Translated},
|
||||
{"Teredo", "2001:0000:4136:e378:8000:63bf:3fff:fdd2", Translated},
|
||||
|
||||
// A translation prefix wrapping a public address is public: the
|
||||
// payload decides, not the wrapper.
|
||||
{"6to4 public payload", "2002:0808:0808::1", Public},
|
||||
{"NAT64 public payload", "64:ff9b::0808:0808", Public},
|
||||
|
||||
// fec0::/10 is the gap that motivated sharing this classifier:
|
||||
// net.IP.IsPrivate covers fc00::/7 and stops there.
|
||||
{"IPv6 site-local", "fec0::1", SiteLocalIPv6},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ip := net.ParseIP(tc.ip)
|
||||
if ip == nil {
|
||||
t.Fatalf("test case has an unparseable address: %s", tc.ip)
|
||||
}
|
||||
class, reason := Classify(ip)
|
||||
if class != tc.class {
|
||||
t.Fatalf("Classify(%s) = %v (%q), want %v", tc.ip, class, reason, tc.class)
|
||||
}
|
||||
if tc.class == Public && reason != "" {
|
||||
t.Fatalf("Classify(%s) returned reason %q for a public address", tc.ip, reason)
|
||||
}
|
||||
if tc.class != Public && reason == "" {
|
||||
t.Fatalf("Classify(%s) returned class %v with no reason", tc.ip, tc.class)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A nil or truncated address must not read as Public: callers use IsPublic to
|
||||
// decide whether to dial, so an unparseable value has to fail closed.
|
||||
func TestClassifyRejectsMalformedAddresses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, ip := range map[string]net.IP{
|
||||
"nil": nil,
|
||||
"empty": {},
|
||||
"truncated": {10, 0, 0},
|
||||
} {
|
||||
class, _ := Classify(ip)
|
||||
if class != Invalid {
|
||||
t.Fatalf("Classify(%s) = %v, want Invalid", name, class)
|
||||
}
|
||||
if IsPublic(ip) {
|
||||
t.Fatalf("IsPublic(%s) = true, want false", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPublic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Documentation ranges are a distinct class precisely because callers
|
||||
// disagree about them; IsPublic is the strict answer.
|
||||
nonPublic := []string{
|
||||
"127.0.0.1",
|
||||
"10.0.0.1",
|
||||
"100.64.0.1",
|
||||
"169.254.169.254",
|
||||
"fec0::1",
|
||||
"2002:a9fe:a9fe::1",
|
||||
"203.0.113.10",
|
||||
}
|
||||
for _, raw := range nonPublic {
|
||||
if IsPublic(net.ParseIP(raw)) {
|
||||
t.Errorf("IsPublic(%s) = true, want false", raw)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"8.8.8.8", "2001:4860:4860::8888"} {
|
||||
if !IsPublic(net.ParseIP(raw)) {
|
||||
t.Errorf("IsPublic(%s) = false, want true", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user