fix(netpolicy): check guest traffic before conntrack accepts, close gateway DNS to other interfaces, block more special-use ranges
- commit
- a70649190fd58a0d001450ba056b2ccf0c33cc74
- author
- brice · · 2026-10-12 15:53 UTC
- parent
- b5ce612
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +337 -24
A internal/netpolicy/netns_test.go +202 -0
| @@ -0,0 +1,202 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package netpolicy | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "os/exec" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite-runner/internal/cmdrun" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // TestRulesetBetweenTwoGuestsAndAForgedSource loads the real ruleset into the | |
| 18 | + | // kernel and sends real packets: two guests (ertap0, ertap1) and an outside | |
| 19 | + | // host hang off veth pairs, each in its own network namespace. It needs | |
| 20 | + | // CAP_NET_ADMIN, which scripts/it-vm-userns.sh provides without root: | |
| 21 | + | // | |
| 22 | + | // ERMITE_RUNNER_IT_NET=1 unshare --user --map-root-user --net go test -run Netns ./internal/netpolicy | |
| 23 | + | func TestRulesetBetweenTwoGuestsAndAForgedSource(t *testing.T) { | |
| 24 | + | if os.Getenv("ERMITE_RUNNER_IT_NET") != "1" { | |
| 25 | + | t.Skip("set ERMITE_RUNNER_IT_NET=1 inside a user+network namespace (scripts/it-vm-userns.sh)") | |
| 26 | + | } | |
| 27 | + | for _, c := range []string{"ip", "nft", "nsenter", "unshare", "python3"} { | |
| 28 | + | if _, err := exec.LookPath(c); err != nil { | |
| 29 | + | t.Skipf("%s not found", c) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | sh := func(args ...string) string { | |
| 33 | + | t.Helper() | |
| 34 | + | out, err := exec.Command(args[0], args[1:]...).CombinedOutput() | |
| 35 | + | if err != nil { | |
| 36 | + | t.Fatalf("%s: %v: %s", strings.Join(args, " "), err, out) | |
| 37 | + | } | |
| 38 | + | return strings.TrimSpace(string(out)) | |
| 39 | + | } | |
| 40 | + | if out, err := exec.Command("ip", "link", "add", "ertapprobe", "type", "dummy").CombinedOutput(); err != nil { | |
| 41 | + | t.Skipf("no CAP_NET_ADMIN: %v: %s", err, out) | |
| 42 | + | } | |
| 43 | + | sh("ip", "link", "del", "ertapprobe") | |
| 44 | + | sh("ip", "link", "set", "lo", "up") | |
| 45 | + | ||
| 46 | + | // Three machines: guest A, guest B, an outside host. | |
| 47 | + | pid := map[string]int{} | |
| 48 | + | for _, n := range []string{"a", "b", "ext"} { | |
| 49 | + | c := exec.Command("unshare", "--net", "sleep", "120") | |
| 50 | + | if err := c.Start(); err != nil { | |
| 51 | + | t.Fatal(err) | |
| 52 | + | } | |
| 53 | + | pid[n] = c.Process.Pid | |
| 54 | + | t.Cleanup(func() { _ = c.Process.Kill(); _ = c.Wait() }) | |
| 55 | + | } | |
| 56 | + | time.Sleep(300 * time.Millisecond) | |
| 57 | + | in := func(n string, args ...string) string { | |
| 58 | + | t.Helper() | |
| 59 | + | return sh(append([]string{"nsenter", fmt.Sprintf("--net=/proc/%d/ns/net", pid[n])}, args...)...) | |
| 60 | + | } | |
| 61 | + | link := func(host, n string) { | |
| 62 | + | sh("ip", "link", "add", host, "type", "veth", "peer", "name", "v0", "netns", fmt.Sprint(pid[n])) | |
| 63 | + | in(n, "ip", "link", "set", "lo", "up") | |
| 64 | + | in(n, "ip", "link", "set", "v0", "up") | |
| 65 | + | sh("sysctl", "-qw", "net.ipv4.conf."+host+".rp_filter=0") | |
| 66 | + | } | |
| 67 | + | link("ertap0", "a") | |
| 68 | + | link("ertap1", "b") | |
| 69 | + | link("extif", "ext") | |
| 70 | + | sh("sysctl", "-qw", "net.ipv4.conf.all.rp_filter=0") | |
| 71 | + | sh("sysctl", "-qw", "net.ipv4.ip_forward=1") | |
| 72 | + | ||
| 73 | + | gA := guest(t, "ertap0", 0) // 10.200.0.1 / .2 | |
| 74 | + | gB := guest(t, "ertap1", 1) // 10.200.0.5 / .6 | |
| 75 | + | for _, g := range []Guest{gA, gB} { | |
| 76 | + | sh("ip", "addr", "add", g.Gateway.String()+"/30", "dev", g.Iface) | |
| 77 | + | sh("ip", "link", "set", g.Iface, "up") | |
| 78 | + | } | |
| 79 | + | sh("ip", "addr", "add", "203.0.114.1/24", "dev", "extif") | |
| 80 | + | sh("ip", "link", "set", "extif", "up") | |
| 81 | + | in("a", "ip", "addr", "add", gA.Addr.String()+"/30", "dev", "v0") | |
| 82 | + | in("a", "ip", "route", "add", "default", "via", gA.Gateway.String()) | |
| 83 | + | in("b", "ip", "addr", "add", gB.Addr.String()+"/30", "dev", "v0") | |
| 84 | + | in("b", "ip", "route", "add", "default", "via", gB.Gateway.String()) | |
| 85 | + | in("ext", "ip", "addr", "add", "203.0.114.2/24", "dev", "v0") | |
| 86 | + | in("ext", "ip", "route", "add", "default", "via", "203.0.114.1") | |
| 87 | + | // Guest A holds guest B's address too, to be able to forge it. | |
| 88 | + | in("a", "ip", "addr", "add", gB.Addr.String()+"/32", "dev", "lo") | |
| 89 | + | ||
| 90 | + | m := NewManager(cmdrun.Exec{}, "nft") | |
| 91 | + | ctx := context.Background() | |
| 92 | + | t.Cleanup(func() { _ = m.Reset(ctx) }) | |
| 93 | + | for _, g := range []Guest{gA, gB} { | |
| 94 | + | if err := m.Add(ctx, g); err != nil { | |
| 95 | + | t.Fatal(err) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // An outside UDP server: logs each payload with the source it saw, and | |
| 100 | + | // answers "pong". | |
| 101 | + | logf := t.TempDir() + "/ext.log" | |
| 102 | + | const server = ` | |
| 103 | + | import socket,sys | |
| 104 | + | s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.bind(("0.0.0.0",9999)) | |
| 105 | + | f=open(sys.argv[1],"a",buffering=1) | |
| 106 | + | while True: | |
| 107 | + | d,a=s.recvfrom(100); f.write(d.decode()+" from "+a[0]+"\n"); s.sendto(b"pong",a) | |
| 108 | + | ` | |
| 109 | + | srv := exec.Command("nsenter", fmt.Sprintf("--net=/proc/%d/ns/net", pid["ext"]), "python3", "-c", server, logf) | |
| 110 | + | if err := srv.Start(); err != nil { | |
| 111 | + | t.Fatal(err) | |
| 112 | + | } | |
| 113 | + | t.Cleanup(func() { _ = srv.Process.Kill(); _ = srv.Wait() }) | |
| 114 | + | time.Sleep(500 * time.Millisecond) | |
| 115 | + | ||
| 116 | + | // udp sends one datagram from machine n (optionally bound to src:sport) and | |
| 117 | + | // reports whether an answer came back. | |
| 118 | + | const client = ` | |
| 119 | + | import socket,sys | |
| 120 | + | dst,payload,src,sport=sys.argv[1],sys.argv[2],sys.argv[3],int(sys.argv[4]) | |
| 121 | + | s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.settimeout(2) | |
| 122 | + | if src: s.bind((src,sport)) | |
| 123 | + | s.sendto(payload.encode(),(dst,int(sys.argv[5]) if len(sys.argv)>5 else 9999)) | |
| 124 | + | try: s.recv(10); print("answer") | |
| 125 | + | except Exception: print("silence") | |
| 126 | + | ` | |
| 127 | + | udp := func(n, dst, payload, src string, sport int, dport int) string { | |
| 128 | + | return in(n, "python3", "-c", client, dst, payload, src, fmt.Sprint(sport), fmt.Sprint(dport)) | |
| 129 | + | } | |
| 130 | + | seen := func() string { b, _ := os.ReadFile(logf); return string(b) } | |
| 131 | + | ||
| 132 | + | // Control: a guest reaches the outside host and gets the reply (NAT and the | |
| 133 | + | // established path work, so the drops below are the policy, not a broken rig). | |
| 134 | + | if got := udp("a", "203.0.114.2", "ctl-a", "", 0, 9999); got != "answer" { | |
| 135 | + | t.Fatalf("control: guest A cannot reach the outside host: %s", got) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // Guests cannot reach each other, directly or through the other's gateway. | |
| 139 | + | if got := udp("a", gB.Addr.String(), "a-to-b", "", 0, 9999); got != "silence" { | |
| 140 | + | t.Errorf("guest A reached guest B: %s", got) | |
| 141 | + | } | |
| 142 | + | // Forged reply (S1): B opens a flow to the outside host from port 5555 and | |
| 143 | + | // keeps listening. Guest A then forges a reply of that flow: source = the | |
| 144 | + | // outside host's address and port, destination = the host's masquerade | |
| 145 | + | // address and B's NAT port. Conntrack would map it onto B's flow and deliver | |
| 146 | + | // it to B as "established" traffic if an established-accept came before the | |
| 147 | + | // source check. | |
| 148 | + | in("a", "ip", "addr", "add", "203.0.114.2/32", "dev", "lo") | |
| 149 | + | bLog := t.TempDir() + "/b.log" | |
| 150 | + | const listener = ` | |
| 151 | + | import socket,sys | |
| 152 | + | s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.settimeout(6) | |
| 153 | + | s.bind(("10.200.0.6",5555)); s.sendto(b"open-flow",("203.0.114.2",9999)) | |
| 154 | + | f=open(sys.argv[1],"a",buffering=1) | |
| 155 | + | try: | |
| 156 | + | while True: | |
| 157 | + | d,a=s.recvfrom(100); f.write(d.decode()+" from "+a[0]+"\n") | |
| 158 | + | except Exception: pass | |
| 159 | + | ` | |
| 160 | + | bProc := exec.Command("nsenter", fmt.Sprintf("--net=/proc/%d/ns/net", pid["b"]), "python3", "-c", listener, bLog) | |
| 161 | + | if err := bProc.Start(); err != nil { | |
| 162 | + | t.Fatal(err) | |
| 163 | + | } | |
| 164 | + | time.Sleep(1500 * time.Millisecond) | |
| 165 | + | if got := udp("a", "203.0.114.1", "forged-reply-to-b", "203.0.114.2", 9999, 5555); got != "silence" { | |
| 166 | + | t.Errorf("unexpected answer to the forgery: %s", got) | |
| 167 | + | } | |
| 168 | + | _ = bProc.Wait() | |
| 169 | + | if b, _ := os.ReadFile(bLog); strings.Contains(string(b), "forged-reply-to-b") { | |
| 170 | + | t.Errorf("guest B received a reply forged by guest A through conntrack:\n%s", b) | |
| 171 | + | } | |
| 172 | + | // A forged source address on a plain forwarded packet is dropped as well. | |
| 173 | + | if got := udp("a", "203.0.114.2", "forged-src", gB.Addr.String(), 7777, 9999); got != "silence" { | |
| 174 | + | t.Errorf("a packet with another guest's source address got through: %s", got) | |
| 175 | + | } | |
| 176 | + | // S6: guest A cannot use guest B's DNS gateway, nor the host's other ports. | |
| 177 | + | if got := udp("a", gB.Gateway.String(), "dns-a-to-b-gw", "", 0, 53); got != "silence" { | |
| 178 | + | t.Errorf("guest A reached guest B's gateway on 53: %s", got) | |
| 179 | + | } | |
| 180 | + | // The outside host cannot open connections to a guest. | |
| 181 | + | if got := in("ext", "python3", "-c", client, gA.Addr.String(), "ext-to-a", "", "0", "9999"); got != "silence" { | |
| 182 | + | t.Errorf("the outside host reached guest A: %s", got) | |
| 183 | + | } | |
| 184 | + | // Special-use ranges (S10) are dropped even though the rig could route them. | |
| 185 | + | in("ext", "ip", "addr", "add", "198.51.100.7/32", "dev", "lo") | |
| 186 | + | sh("ip", "route", "add", "198.51.100.7/32", "via", "203.0.114.2") | |
| 187 | + | if got := udp("a", "198.51.100.7", "doc-range", "", 0, 9999); got != "silence" { | |
| 188 | + | t.Errorf("TEST-NET-2 reached: %s", got) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | out := seen() | |
| 192 | + | for _, bad := range []string{"forged-src", "a-to-b", "ext-to-a", "doc-range"} { | |
| 193 | + | if strings.Contains(out, bad) { | |
| 194 | + | t.Errorf("outside host saw %q:\n%s", bad, out) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | for _, good := range []string{"ctl-a", "open-flow"} { | |
| 198 | + | if !strings.Contains(out, good) { | |
| 199 | + | t.Errorf("outside host did not see %q:\n%s", good, out) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | } | |
| @@ -0,0 +1,202 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package netpolicy | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "os/exec" | |||
| 10 | + | "strings" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite-runner/internal/cmdrun" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // TestRulesetBetweenTwoGuestsAndAForgedSource loads the real ruleset into the | |||
| 18 | + | // kernel and sends real packets: two guests (ertap0, ertap1) and an outside | |||
| 19 | + | // host hang off veth pairs, each in its own network namespace. It needs | |||
| 20 | + | // CAP_NET_ADMIN, which scripts/it-vm-userns.sh provides without root: | |||
| 21 | + | // | |||
| 22 | + | // ERMITE_RUNNER_IT_NET=1 unshare --user --map-root-user --net go test -run Netns ./internal/netpolicy | |||
| 23 | + | func TestRulesetBetweenTwoGuestsAndAForgedSource(t *testing.T) { | |||
| 24 | + | if os.Getenv("ERMITE_RUNNER_IT_NET") != "1" { | |||
| 25 | + | t.Skip("set ERMITE_RUNNER_IT_NET=1 inside a user+network namespace (scripts/it-vm-userns.sh)") | |||
| 26 | + | } | |||
| 27 | + | for _, c := range []string{"ip", "nft", "nsenter", "unshare", "python3"} { | |||
| 28 | + | if _, err := exec.LookPath(c); err != nil { | |||
| 29 | + | t.Skipf("%s not found", c) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | sh := func(args ...string) string { | |||
| 33 | + | t.Helper() | |||
| 34 | + | out, err := exec.Command(args[0], args[1:]...).CombinedOutput() | |||
| 35 | + | if err != nil { | |||
| 36 | + | t.Fatalf("%s: %v: %s", strings.Join(args, " "), err, out) | |||
| 37 | + | } | |||
| 38 | + | return strings.TrimSpace(string(out)) | |||
| 39 | + | } | |||
| 40 | + | if out, err := exec.Command("ip", "link", "add", "ertapprobe", "type", "dummy").CombinedOutput(); err != nil { | |||
| 41 | + | t.Skipf("no CAP_NET_ADMIN: %v: %s", err, out) | |||
| 42 | + | } | |||
| 43 | + | sh("ip", "link", "del", "ertapprobe") | |||
| 44 | + | sh("ip", "link", "set", "lo", "up") | |||
| 45 | + | ||||
| 46 | + | // Three machines: guest A, guest B, an outside host. | |||
| 47 | + | pid := map[string]int{} | |||
| 48 | + | for _, n := range []string{"a", "b", "ext"} { | |||
| 49 | + | c := exec.Command("unshare", "--net", "sleep", "120") | |||
| 50 | + | if err := c.Start(); err != nil { | |||
| 51 | + | t.Fatal(err) | |||
| 52 | + | } | |||
| 53 | + | pid[n] = c.Process.Pid | |||
| 54 | + | t.Cleanup(func() { _ = c.Process.Kill(); _ = c.Wait() }) | |||
| 55 | + | } | |||
| 56 | + | time.Sleep(300 * time.Millisecond) | |||
| 57 | + | in := func(n string, args ...string) string { | |||
| 58 | + | t.Helper() | |||
| 59 | + | return sh(append([]string{"nsenter", fmt.Sprintf("--net=/proc/%d/ns/net", pid[n])}, args...)...) | |||
| 60 | + | } | |||
| 61 | + | link := func(host, n string) { | |||
| 62 | + | sh("ip", "link", "add", host, "type", "veth", "peer", "name", "v0", "netns", fmt.Sprint(pid[n])) | |||
| 63 | + | in(n, "ip", "link", "set", "lo", "up") | |||
| 64 | + | in(n, "ip", "link", "set", "v0", "up") | |||
| 65 | + | sh("sysctl", "-qw", "net.ipv4.conf."+host+".rp_filter=0") | |||
| 66 | + | } | |||
| 67 | + | link("ertap0", "a") | |||
| 68 | + | link("ertap1", "b") | |||
| 69 | + | link("extif", "ext") | |||
| 70 | + | sh("sysctl", "-qw", "net.ipv4.conf.all.rp_filter=0") | |||
| 71 | + | sh("sysctl", "-qw", "net.ipv4.ip_forward=1") | |||
| 72 | + | ||||
| 73 | + | gA := guest(t, "ertap0", 0) // 10.200.0.1 / .2 | |||
| 74 | + | gB := guest(t, "ertap1", 1) // 10.200.0.5 / .6 | |||
| 75 | + | for _, g := range []Guest{gA, gB} { | |||
| 76 | + | sh("ip", "addr", "add", g.Gateway.String()+"/30", "dev", g.Iface) | |||
| 77 | + | sh("ip", "link", "set", g.Iface, "up") | |||
| 78 | + | } | |||
| 79 | + | sh("ip", "addr", "add", "203.0.114.1/24", "dev", "extif") | |||
| 80 | + | sh("ip", "link", "set", "extif", "up") | |||
| 81 | + | in("a", "ip", "addr", "add", gA.Addr.String()+"/30", "dev", "v0") | |||
| 82 | + | in("a", "ip", "route", "add", "default", "via", gA.Gateway.String()) | |||
| 83 | + | in("b", "ip", "addr", "add", gB.Addr.String()+"/30", "dev", "v0") | |||
| 84 | + | in("b", "ip", "route", "add", "default", "via", gB.Gateway.String()) | |||
| 85 | + | in("ext", "ip", "addr", "add", "203.0.114.2/24", "dev", "v0") | |||
| 86 | + | in("ext", "ip", "route", "add", "default", "via", "203.0.114.1") | |||
| 87 | + | // Guest A holds guest B's address too, to be able to forge it. | |||
| 88 | + | in("a", "ip", "addr", "add", gB.Addr.String()+"/32", "dev", "lo") | |||
| 89 | + | ||||
| 90 | + | m := NewManager(cmdrun.Exec{}, "nft") | |||
| 91 | + | ctx := context.Background() | |||
| 92 | + | t.Cleanup(func() { _ = m.Reset(ctx) }) | |||
| 93 | + | for _, g := range []Guest{gA, gB} { | |||
| 94 | + | if err := m.Add(ctx, g); err != nil { | |||
| 95 | + | t.Fatal(err) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // An outside UDP server: logs each payload with the source it saw, and | |||
| 100 | + | // answers "pong". | |||
| 101 | + | logf := t.TempDir() + "/ext.log" | |||
| 102 | + | const server = ` | |||
| 103 | + | import socket,sys | |||
| 104 | + | s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.bind(("0.0.0.0",9999)) | |||
| 105 | + | f=open(sys.argv[1],"a",buffering=1) | |||
| 106 | + | while True: | |||
| 107 | + | d,a=s.recvfrom(100); f.write(d.decode()+" from "+a[0]+"\n"); s.sendto(b"pong",a) | |||
| 108 | + | ` | |||
| 109 | + | srv := exec.Command("nsenter", fmt.Sprintf("--net=/proc/%d/ns/net", pid["ext"]), "python3", "-c", server, logf) | |||
| 110 | + | if err := srv.Start(); err != nil { | |||
| 111 | + | t.Fatal(err) | |||
| 112 | + | } | |||
| 113 | + | t.Cleanup(func() { _ = srv.Process.Kill(); _ = srv.Wait() }) | |||
| 114 | + | time.Sleep(500 * time.Millisecond) | |||
| 115 | + | ||||
| 116 | + | // udp sends one datagram from machine n (optionally bound to src:sport) and | |||
| 117 | + | // reports whether an answer came back. | |||
| 118 | + | const client = ` | |||
| 119 | + | import socket,sys | |||
| 120 | + | dst,payload,src,sport=sys.argv[1],sys.argv[2],sys.argv[3],int(sys.argv[4]) | |||
| 121 | + | s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.settimeout(2) | |||
| 122 | + | if src: s.bind((src,sport)) | |||
| 123 | + | s.sendto(payload.encode(),(dst,int(sys.argv[5]) if len(sys.argv)>5 else 9999)) | |||
| 124 | + | try: s.recv(10); print("answer") | |||
| 125 | + | except Exception: print("silence") | |||
| 126 | + | ` | |||
| 127 | + | udp := func(n, dst, payload, src string, sport int, dport int) string { | |||
| 128 | + | return in(n, "python3", "-c", client, dst, payload, src, fmt.Sprint(sport), fmt.Sprint(dport)) | |||
| 129 | + | } | |||
| 130 | + | seen := func() string { b, _ := os.ReadFile(logf); return string(b) } | |||
| 131 | + | ||||
| 132 | + | // Control: a guest reaches the outside host and gets the reply (NAT and the | |||
| 133 | + | // established path work, so the drops below are the policy, not a broken rig). | |||
| 134 | + | if got := udp("a", "203.0.114.2", "ctl-a", "", 0, 9999); got != "answer" { | |||
| 135 | + | t.Fatalf("control: guest A cannot reach the outside host: %s", got) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // Guests cannot reach each other, directly or through the other's gateway. | |||
| 139 | + | if got := udp("a", gB.Addr.String(), "a-to-b", "", 0, 9999); got != "silence" { | |||
| 140 | + | t.Errorf("guest A reached guest B: %s", got) | |||
| 141 | + | } | |||
| 142 | + | // Forged reply (S1): B opens a flow to the outside host from port 5555 and | |||
| 143 | + | // keeps listening. Guest A then forges a reply of that flow: source = the | |||
| 144 | + | // outside host's address and port, destination = the host's masquerade | |||
| 145 | + | // address and B's NAT port. Conntrack would map it onto B's flow and deliver | |||
| 146 | + | // it to B as "established" traffic if an established-accept came before the | |||
| 147 | + | // source check. | |||
| 148 | + | in("a", "ip", "addr", "add", "203.0.114.2/32", "dev", "lo") | |||
| 149 | + | bLog := t.TempDir() + "/b.log" | |||
| 150 | + | const listener = ` | |||
| 151 | + | import socket,sys | |||
| 152 | + | s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.settimeout(6) | |||
| 153 | + | s.bind(("10.200.0.6",5555)); s.sendto(b"open-flow",("203.0.114.2",9999)) | |||
| 154 | + | f=open(sys.argv[1],"a",buffering=1) | |||
| 155 | + | try: | |||
| 156 | + | while True: | |||
| 157 | + | d,a=s.recvfrom(100); f.write(d.decode()+" from "+a[0]+"\n") | |||
| 158 | + | except Exception: pass | |||
| 159 | + | ` | |||
| 160 | + | bProc := exec.Command("nsenter", fmt.Sprintf("--net=/proc/%d/ns/net", pid["b"]), "python3", "-c", listener, bLog) | |||
| 161 | + | if err := bProc.Start(); err != nil { | |||
| 162 | + | t.Fatal(err) | |||
| 163 | + | } | |||
| 164 | + | time.Sleep(1500 * time.Millisecond) | |||
| 165 | + | if got := udp("a", "203.0.114.1", "forged-reply-to-b", "203.0.114.2", 9999, 5555); got != "silence" { | |||
| 166 | + | t.Errorf("unexpected answer to the forgery: %s", got) | |||
| 167 | + | } | |||
| 168 | + | _ = bProc.Wait() | |||
| 169 | + | if b, _ := os.ReadFile(bLog); strings.Contains(string(b), "forged-reply-to-b") { | |||
| 170 | + | t.Errorf("guest B received a reply forged by guest A through conntrack:\n%s", b) | |||
| 171 | + | } | |||
| 172 | + | // A forged source address on a plain forwarded packet is dropped as well. | |||
| 173 | + | if got := udp("a", "203.0.114.2", "forged-src", gB.Addr.String(), 7777, 9999); got != "silence" { | |||
| 174 | + | t.Errorf("a packet with another guest's source address got through: %s", got) | |||
| 175 | + | } | |||
| 176 | + | // S6: guest A cannot use guest B's DNS gateway, nor the host's other ports. | |||
| 177 | + | if got := udp("a", gB.Gateway.String(), "dns-a-to-b-gw", "", 0, 53); got != "silence" { | |||
| 178 | + | t.Errorf("guest A reached guest B's gateway on 53: %s", got) | |||
| 179 | + | } | |||
| 180 | + | // The outside host cannot open connections to a guest. | |||
| 181 | + | if got := in("ext", "python3", "-c", client, gA.Addr.String(), "ext-to-a", "", "0", "9999"); got != "silence" { | |||
| 182 | + | t.Errorf("the outside host reached guest A: %s", got) | |||
| 183 | + | } | |||
| 184 | + | // Special-use ranges (S10) are dropped even though the rig could route them. | |||
| 185 | + | in("ext", "ip", "addr", "add", "198.51.100.7/32", "dev", "lo") | |||
| 186 | + | sh("ip", "route", "add", "198.51.100.7/32", "via", "203.0.114.2") | |||
| 187 | + | if got := udp("a", "198.51.100.7", "doc-range", "", 0, 9999); got != "silence" { | |||
| 188 | + | t.Errorf("TEST-NET-2 reached: %s", got) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | out := seen() | |||
| 192 | + | for _, bad := range []string{"forged-src", "a-to-b", "ext-to-a", "doc-range"} { | |||
| 193 | + | if strings.Contains(out, bad) { | |||
| 194 | + | t.Errorf("outside host saw %q:\n%s", bad, out) | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | for _, good := range []string{"ctl-a", "open-flow"} { | |||
| 198 | + | if !strings.Contains(out, good) { | |||
| 199 | + | t.Errorf("outside host did not see %q:\n%s", good, out) | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | } | |||
M internal/netpolicy/netpolicy.go +52 -14
| @@ -10,7 +10,8 @@ | |||
| 10 | 10 | // container): | |
| 11 | 11 | // - to the host itself (input hook): only DNS (udp/tcp 53) to the gateway | |
| 12 | 12 | // address of that guest, where the runner's DNS forwarder listens; every | |
| 13 | - | // other packet is dropped; | |
| 13 | + | // other packet is dropped, and port 53 of a gateway is closed to every | |
| 14 | + | // other interface; | |
| 14 | 15 | // - IPv6 from the guest is dropped (the guest also disables IPv6); | |
| 15 | 16 | // - forwarded traffic must carry the guest's own source address; | |
| 16 | 17 | // - forwarded traffic to RFC 1918, CGNAT 100.64.0.0/10 (VPN overlays), | |
| @@ -42,15 +43,29 @@ var IfacePrefixes = []string{"ertap", "erbr"} | |||
| 42 | 43 | ||
| 43 | 44 | // blockedV4 are the destinations a guest may never reach. | |
| 44 | 45 | var blockedV4 = []string{ | |
| 45 | - | "0.0.0.0/8", // "this network" | |
| 46 | - | "10.0.0.0/8", // RFC 1918 | |
| 47 | - | "100.64.0.0/10", // RFC 6598 shared address space (CGNAT, VPN overlays) | |
| 48 | - | "127.0.0.0/8", // loopback | |
| 49 | - | "169.254.0.0/16", // link-local, incl. the 169.254.169.254 metadata service | |
| 50 | - | "172.16.0.0/12", // RFC 1918 | |
| 51 | - | "192.168.0.0/16", // RFC 1918 | |
| 52 | - | "224.0.0.0/4", // multicast | |
| 53 | - | "240.0.0.0/4", // reserved, incl. limited broadcast | |
| 46 | + | "0.0.0.0/8", // "this network" | |
| 47 | + | "10.0.0.0/8", // RFC 1918 | |
| 48 | + | "100.64.0.0/10", // RFC 6598 shared address space (CGNAT, VPN overlays) | |
| 49 | + | "127.0.0.0/8", // loopback | |
| 50 | + | "169.254.0.0/16", // link-local, incl. the 169.254.169.254 metadata service | |
| 51 | + | "172.16.0.0/12", // RFC 1918 | |
| 52 | + | "192.0.0.0/24", // IETF protocol assignments | |
| 53 | + | "192.0.2.0/24", // TEST-NET-1 | |
| 54 | + | "192.168.0.0/16", // RFC 1918 | |
| 55 | + | "198.18.0.0/15", // benchmarking | |
| 56 | + | "198.51.100.0/24", // TEST-NET-2 | |
| 57 | + | "203.0.113.0/24", // TEST-NET-3 | |
| 58 | + | "224.0.0.0/4", // multicast | |
| 59 | + | "240.0.0.0/4", // reserved, incl. limited broadcast | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // prefixSet renders IfacePrefixes as an nft anonymous set of interface wildcards. | |
| 63 | + | func prefixSet() string { | |
| 64 | + | q := make([]string, len(IfacePrefixes)) | |
| 65 | + | for i, p := range IfacePrefixes { | |
| 66 | + | q[i] = `"` + p + `*"` | |
| 67 | + | } | |
| 68 | + | return "{ " + strings.Join(q, ", ") + " }" | |
| 54 | 69 | } | |
| 55 | 70 | ||
| 56 | 71 | // Guest is one sandbox network attachment. | |
| @@ -129,19 +144,31 @@ func Ruleset(guests []Guest) string { | |||
| 129 | 144 | for _, p := range IfacePrefixes { | |
| 130 | 145 | fmt.Fprintf(&b, "\t\tiifname \"%s*\" meta nfproto ipv6 drop\n", p) | |
| 131 | 146 | } | |
| 147 | + | // The DNS forwarder of a guest answers that guest only: port 53 of a gateway | |
| 148 | + | // address is dropped from every other interface (other guests, the LAN). | |
| 149 | + | for _, g := range gs { | |
| 150 | + | for _, proto := range []string{"udp", "tcp"} { | |
| 151 | + | fmt.Fprintf(&b, "\t\tip daddr %s %s dport 53 iifname != \"%s\" drop\n", g.Gateway, proto, g.Iface) | |
| 152 | + | } | |
| 153 | + | } | |
| 132 | 154 | for _, g := range gs { | |
| 133 | 155 | for _, proto := range []string{"udp", "tcp"} { | |
| 134 | 156 | fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s %s dport 53 accept\n", g.Iface, g.Addr, g.Gateway, proto) | |
| 135 | 157 | } | |
| 136 | 158 | fmt.Fprintf(&b, "\t\tiifname \"%s\" drop\n", g.Iface) | |
| 137 | 159 | } | |
| 160 | + | // An interface with our prefix that no live guest owns (a leftover) is | |
| 161 | + | // dropped as well. | |
| 162 | + | for _, p := range IfacePrefixes { | |
| 163 | + | fmt.Fprintf(&b, "\t\tiifname \"%s*\" drop\n", p) | |
| 164 | + | } | |
| 138 | 165 | b.WriteString("\t}\n") | |
| 139 | 166 | ||
| 167 | + | // Forward chain. Order matters: everything a guest sends is checked (family, | |
| 168 | + | // source address, destination ranges) BEFORE any "established" accept, or a | |
| 169 | + | // guest could forge the source of another guest's flow and have it accepted | |
| 170 | + | // as a reply. | |
| 140 | 171 | b.WriteString("\tchain forward {\n\t\ttype filter hook forward priority filter; policy accept;\n") | |
| 141 | - | for _, p := range IfacePrefixes { | |
| 142 | - | fmt.Fprintf(&b, "\t\toifname \"%s*\" ct state established,related accept\n", p) | |
| 143 | - | fmt.Fprintf(&b, "\t\toifname \"%s*\" drop\n", p) | |
| 144 | - | } | |
| 145 | 172 | for _, p := range IfacePrefixes { | |
| 146 | 173 | fmt.Fprintf(&b, "\t\tiifname \"%s*\" meta nfproto ipv6 drop\n", p) | |
| 147 | 174 | } | |
| @@ -149,6 +176,17 @@ func Ruleset(guests []Guest) string { | |||
| 149 | 176 | fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr != %s drop\n", g.Iface, g.Addr) | |
| 150 | 177 | fmt.Fprintf(&b, "\t\tiifname \"%s\" ip daddr @blocked_v4 drop\n", g.Iface) | |
| 151 | 178 | } | |
| 179 | + | // Replies from outside to a guest; never for packets that come from a guest. | |
| 180 | + | b.WriteString("\t\tiifname != " + prefixSet() + " oifname " + prefixSet() + " ct state established,related accept\n") | |
| 181 | + | for _, p := range IfacePrefixes { | |
| 182 | + | fmt.Fprintf(&b, "\t\toifname \"%s*\" drop\n", p) | |
| 183 | + | } | |
| 184 | + | for _, g := range gs { | |
| 185 | + | fmt.Fprintf(&b, "\t\tiifname \"%s\" accept\n", g.Iface) | |
| 186 | + | } | |
| 187 | + | for _, p := range IfacePrefixes { | |
| 188 | + | fmt.Fprintf(&b, "\t\tiifname \"%s*\" drop\n", p) | |
| 189 | + | } | |
| 152 | 190 | b.WriteString("\t}\n") | |
| 153 | 191 | ||
| 154 | 192 | b.WriteString("\tchain postrouting {\n\t\ttype nat hook postrouting priority srcnat; policy accept;\n") | |
| @@ -10,7 +10,8 @@ | |||||
| 10 | // container): | 10 | // container): | ||
| 11 | // - to the host itself (input hook): only DNS (udp/tcp 53) to the gateway | 11 | // - to the host itself (input hook): only DNS (udp/tcp 53) to the gateway | ||
| 12 | // address of that guest, where the runner's DNS forwarder listens; every | 12 | // address of that guest, where the runner's DNS forwarder listens; every | ||
| 13 | - | // other packet is dropped; | 13 | + | // other packet is dropped, and port 53 of a gateway is closed to every |
| 14 | + | // other interface; | |||
| 14 | // - IPv6 from the guest is dropped (the guest also disables IPv6); | 15 | // - IPv6 from the guest is dropped (the guest also disables IPv6); | ||
| 15 | // - forwarded traffic must carry the guest's own source address; | 16 | // - forwarded traffic must carry the guest's own source address; | ||
| 16 | // - forwarded traffic to RFC 1918, CGNAT 100.64.0.0/10 (VPN overlays), | 17 | // - forwarded traffic to RFC 1918, CGNAT 100.64.0.0/10 (VPN overlays), | ||
| @@ -42,15 +43,29 @@ var IfacePrefixes = []string{"ertap", "erbr"} | |||||
| 42 | 43 | ||||
| 43 | // blockedV4 are the destinations a guest may never reach. | 44 | // blockedV4 are the destinations a guest may never reach. | ||
| 44 | var blockedV4 = []string{ | 45 | var blockedV4 = []string{ | ||
| 45 | - | "0.0.0.0/8", // "this network" | 46 | + | "0.0.0.0/8", // "this network" |
| 46 | - | "10.0.0.0/8", // RFC 1918 | 47 | + | "10.0.0.0/8", // RFC 1918 |
| 47 | - | "100.64.0.0/10", // RFC 6598 shared address space (CGNAT, VPN overlays) | 48 | + | "100.64.0.0/10", // RFC 6598 shared address space (CGNAT, VPN overlays) |
| 48 | - | "127.0.0.0/8", // loopback | 49 | + | "127.0.0.0/8", // loopback |
| 49 | - | "169.254.0.0/16", // link-local, incl. the 169.254.169.254 metadata service | 50 | + | "169.254.0.0/16", // link-local, incl. the 169.254.169.254 metadata service |
| 50 | - | "172.16.0.0/12", // RFC 1918 | 51 | + | "172.16.0.0/12", // RFC 1918 |
| 51 | - | "192.168.0.0/16", // RFC 1918 | 52 | + | "192.0.0.0/24", // IETF protocol assignments |
| 52 | - | "224.0.0.0/4", // multicast | 53 | + | "192.0.2.0/24", // TEST-NET-1 |
| 53 | - | "240.0.0.0/4", // reserved, incl. limited broadcast | 54 | + | "192.168.0.0/16", // RFC 1918 |
| 55 | + | "198.18.0.0/15", // benchmarking | |||
| 56 | + | "198.51.100.0/24", // TEST-NET-2 | |||
| 57 | + | "203.0.113.0/24", // TEST-NET-3 | |||
| 58 | + | "224.0.0.0/4", // multicast | |||
| 59 | + | "240.0.0.0/4", // reserved, incl. limited broadcast | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // prefixSet renders IfacePrefixes as an nft anonymous set of interface wildcards. | |||
| 63 | + | func prefixSet() string { | |||
| 64 | + | q := make([]string, len(IfacePrefixes)) | |||
| 65 | + | for i, p := range IfacePrefixes { | |||
| 66 | + | q[i] = `"` + p + `*"` | |||
| 67 | + | } | |||
| 68 | + | return "{ " + strings.Join(q, ", ") + " }" | |||
| 54 | } | 69 | } | ||
| 55 | 70 | ||||
| 56 | // Guest is one sandbox network attachment. | 71 | // Guest is one sandbox network attachment. | ||
| @@ -129,19 +144,31 @@ func Ruleset(guests []Guest) string { | |||||
| 129 | for _, p := range IfacePrefixes { | 144 | for _, p := range IfacePrefixes { | ||
| 130 | fmt.Fprintf(&b, "\t\tiifname \"%s*\" meta nfproto ipv6 drop\n", p) | 145 | fmt.Fprintf(&b, "\t\tiifname \"%s*\" meta nfproto ipv6 drop\n", p) | ||
| 131 | } | 146 | } | ||
| 147 | + | // The DNS forwarder of a guest answers that guest only: port 53 of a gateway | |||
| 148 | + | // address is dropped from every other interface (other guests, the LAN). | |||
| 149 | + | for _, g := range gs { | |||
| 150 | + | for _, proto := range []string{"udp", "tcp"} { | |||
| 151 | + | fmt.Fprintf(&b, "\t\tip daddr %s %s dport 53 iifname != \"%s\" drop\n", g.Gateway, proto, g.Iface) | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 132 | for _, g := range gs { | 154 | for _, g := range gs { | ||
| 133 | for _, proto := range []string{"udp", "tcp"} { | 155 | for _, proto := range []string{"udp", "tcp"} { | ||
| 134 | fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s %s dport 53 accept\n", g.Iface, g.Addr, g.Gateway, proto) | 156 | fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s %s dport 53 accept\n", g.Iface, g.Addr, g.Gateway, proto) | ||
| 135 | } | 157 | } | ||
| 136 | fmt.Fprintf(&b, "\t\tiifname \"%s\" drop\n", g.Iface) | 158 | fmt.Fprintf(&b, "\t\tiifname \"%s\" drop\n", g.Iface) | ||
| 137 | } | 159 | } | ||
| 160 | + | // An interface with our prefix that no live guest owns (a leftover) is | |||
| 161 | + | // dropped as well. | |||
| 162 | + | for _, p := range IfacePrefixes { | |||
| 163 | + | fmt.Fprintf(&b, "\t\tiifname \"%s*\" drop\n", p) | |||
| 164 | + | } | |||
| 138 | b.WriteString("\t}\n") | 165 | b.WriteString("\t}\n") | ||
| 139 | 166 | ||||
| 167 | + | // Forward chain. Order matters: everything a guest sends is checked (family, | |||
| 168 | + | // source address, destination ranges) BEFORE any "established" accept, or a | |||
| 169 | + | // guest could forge the source of another guest's flow and have it accepted | |||
| 170 | + | // as a reply. | |||
| 140 | b.WriteString("\tchain forward {\n\t\ttype filter hook forward priority filter; policy accept;\n") | 171 | b.WriteString("\tchain forward {\n\t\ttype filter hook forward priority filter; policy accept;\n") | ||
| 141 | - | for _, p := range IfacePrefixes { | |||
| 142 | - | fmt.Fprintf(&b, "\t\toifname \"%s*\" ct state established,related accept\n", p) | |||
| 143 | - | fmt.Fprintf(&b, "\t\toifname \"%s*\" drop\n", p) | |||
| 144 | - | } | |||
| 145 | for _, p := range IfacePrefixes { | 172 | for _, p := range IfacePrefixes { | ||
| 146 | fmt.Fprintf(&b, "\t\tiifname \"%s*\" meta nfproto ipv6 drop\n", p) | 173 | fmt.Fprintf(&b, "\t\tiifname \"%s*\" meta nfproto ipv6 drop\n", p) | ||
| 147 | } | 174 | } | ||
| @@ -149,6 +176,17 @@ func Ruleset(guests []Guest) string { | |||||
| 149 | fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr != %s drop\n", g.Iface, g.Addr) | 176 | fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr != %s drop\n", g.Iface, g.Addr) | ||
| 150 | fmt.Fprintf(&b, "\t\tiifname \"%s\" ip daddr @blocked_v4 drop\n", g.Iface) | 177 | fmt.Fprintf(&b, "\t\tiifname \"%s\" ip daddr @blocked_v4 drop\n", g.Iface) | ||
| 151 | } | 178 | } | ||
| 179 | + | // Replies from outside to a guest; never for packets that come from a guest. | |||
| 180 | + | b.WriteString("\t\tiifname != " + prefixSet() + " oifname " + prefixSet() + " ct state established,related accept\n") | |||
| 181 | + | for _, p := range IfacePrefixes { | |||
| 182 | + | fmt.Fprintf(&b, "\t\toifname \"%s*\" drop\n", p) | |||
| 183 | + | } | |||
| 184 | + | for _, g := range gs { | |||
| 185 | + | fmt.Fprintf(&b, "\t\tiifname \"%s\" accept\n", g.Iface) | |||
| 186 | + | } | |||
| 187 | + | for _, p := range IfacePrefixes { | |||
| 188 | + | fmt.Fprintf(&b, "\t\tiifname \"%s*\" drop\n", p) | |||
| 189 | + | } | |||
| 152 | b.WriteString("\t}\n") | 190 | b.WriteString("\t}\n") | ||
| 153 | 191 | ||||
| 154 | b.WriteString("\tchain postrouting {\n\t\ttype nat hook postrouting priority srcnat; policy accept;\n") | 192 | b.WriteString("\tchain postrouting {\n\t\ttype nat hook postrouting priority srcnat; policy accept;\n") | ||
M internal/netpolicy/netpolicy_test.go +58 -0
| @@ -78,6 +78,64 @@ func TestRulesetPolicyInvariants(t *testing.T) { | |||
| 78 | 78 | } | |
| 79 | 79 | } | |
| 80 | 80 | ||
| 81 | + | // S1: nothing a guest sends may be accepted by a conntrack rule before the | |
| 82 | + | // per-guest checks: a forged "reply" from guest A to guest B's flow would | |
| 83 | + | // otherwise be accepted as established traffic. | |
| 84 | + | func TestForwardChainChecksGuestTrafficBeforeEstablished(t *testing.T) { | |
| 85 | + | rs := Ruleset([]Guest{guest(t, "ertap0", 0), guest(t, "erbr1", 1)}) | |
| 86 | + | fwd := rs[strings.Index(rs, "chain forward"):strings.Index(rs, "chain postrouting")] | |
| 87 | + | lines := strings.Split(fwd, "\n") | |
| 88 | + | idx := func(sub string) int { | |
| 89 | + | for i, l := range lines { | |
| 90 | + | if strings.Contains(l, sub) { | |
| 91 | + | return i | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | t.Fatalf("forward chain lacks %q:\n%s", sub, fwd) | |
| 95 | + | return -1 | |
| 96 | + | } | |
| 97 | + | est := idx("ct state established,related accept") | |
| 98 | + | for _, must := range []string{ | |
| 99 | + | `iifname "ertap*" meta nfproto ipv6 drop`, `iifname "erbr*" meta nfproto ipv6 drop`, | |
| 100 | + | `iifname "ertap0" ip saddr != 10.200.0.2 drop`, `iifname "ertap0" ip daddr @blocked_v4 drop`, | |
| 101 | + | `iifname "erbr1" ip saddr != 10.200.0.6 drop`, `iifname "erbr1" ip daddr @blocked_v4 drop`, | |
| 102 | + | } { | |
| 103 | + | if idx(must) > est { | |
| 104 | + | t.Errorf("%q must come before the established accept", must) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | // And the established accept itself never applies to packets that enter | |
| 108 | + | // through a sandbox interface. | |
| 109 | + | if !strings.Contains(lines[est], `iifname != { "ertap*", "erbr*" }`) { | |
| 110 | + | t.Errorf("established accept must exclude sandbox ingress: %s", lines[est]) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // S6: port 53 of a gateway address is closed to every interface but its guest's. | |
| 115 | + | func TestInputChainClosesGatewayDNSToOtherInterfaces(t *testing.T) { | |
| 116 | + | rs := Ruleset([]Guest{guest(t, "ertap0", 0), guest(t, "ertap1", 1)}) | |
| 117 | + | for _, want := range []string{ | |
| 118 | + | `ip daddr 10.200.0.1 udp dport 53 iifname != "ertap0" drop`, | |
| 119 | + | `ip daddr 10.200.0.1 tcp dport 53 iifname != "ertap0" drop`, | |
| 120 | + | `ip daddr 10.200.0.5 udp dport 53 iifname != "ertap1" drop`, | |
| 121 | + | `ip daddr 10.200.0.5 tcp dport 53 iifname != "ertap1" drop`, | |
| 122 | + | } { | |
| 123 | + | if !strings.Contains(rs, want) { | |
| 124 | + | t.Errorf("lacks %q", want) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // S10: documentation, test-net and benchmarking ranges are blocked too. | |
| 130 | + | func TestBlockedSetCoversSpecialUseRanges(t *testing.T) { | |
| 131 | + | rs := Ruleset([]Guest{guest(t, "ertap0", 0)}) | |
| 132 | + | for _, r := range []string{"192.0.0.0/24", "192.0.2.0/24", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4"} { | |
| 133 | + | if !strings.Contains(rs, r) { | |
| 134 | + | t.Errorf("blocked set lacks %s", r) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 81 | 139 | func TestSubnet(t *testing.T) { | |
| 82 | 140 | base := netip.MustParsePrefix("10.200.0.0/16") | |
| 83 | 141 | n, err := Subnet(base, 3) | |
| @@ -78,6 +78,64 @@ func TestRulesetPolicyInvariants(t *testing.T) { | |||||
| 78 | } | 78 | } | ||
| 79 | } | 79 | } | ||
| 80 | 80 | ||||
| 81 | + | // S1: nothing a guest sends may be accepted by a conntrack rule before the | |||
| 82 | + | // per-guest checks: a forged "reply" from guest A to guest B's flow would | |||
| 83 | + | // otherwise be accepted as established traffic. | |||
| 84 | + | func TestForwardChainChecksGuestTrafficBeforeEstablished(t *testing.T) { | |||
| 85 | + | rs := Ruleset([]Guest{guest(t, "ertap0", 0), guest(t, "erbr1", 1)}) | |||
| 86 | + | fwd := rs[strings.Index(rs, "chain forward"):strings.Index(rs, "chain postrouting")] | |||
| 87 | + | lines := strings.Split(fwd, "\n") | |||
| 88 | + | idx := func(sub string) int { | |||
| 89 | + | for i, l := range lines { | |||
| 90 | + | if strings.Contains(l, sub) { | |||
| 91 | + | return i | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | t.Fatalf("forward chain lacks %q:\n%s", sub, fwd) | |||
| 95 | + | return -1 | |||
| 96 | + | } | |||
| 97 | + | est := idx("ct state established,related accept") | |||
| 98 | + | for _, must := range []string{ | |||
| 99 | + | `iifname "ertap*" meta nfproto ipv6 drop`, `iifname "erbr*" meta nfproto ipv6 drop`, | |||
| 100 | + | `iifname "ertap0" ip saddr != 10.200.0.2 drop`, `iifname "ertap0" ip daddr @blocked_v4 drop`, | |||
| 101 | + | `iifname "erbr1" ip saddr != 10.200.0.6 drop`, `iifname "erbr1" ip daddr @blocked_v4 drop`, | |||
| 102 | + | } { | |||
| 103 | + | if idx(must) > est { | |||
| 104 | + | t.Errorf("%q must come before the established accept", must) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | // And the established accept itself never applies to packets that enter | |||
| 108 | + | // through a sandbox interface. | |||
| 109 | + | if !strings.Contains(lines[est], `iifname != { "ertap*", "erbr*" }`) { | |||
| 110 | + | t.Errorf("established accept must exclude sandbox ingress: %s", lines[est]) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // S6: port 53 of a gateway address is closed to every interface but its guest's. | |||
| 115 | + | func TestInputChainClosesGatewayDNSToOtherInterfaces(t *testing.T) { | |||
| 116 | + | rs := Ruleset([]Guest{guest(t, "ertap0", 0), guest(t, "ertap1", 1)}) | |||
| 117 | + | for _, want := range []string{ | |||
| 118 | + | `ip daddr 10.200.0.1 udp dport 53 iifname != "ertap0" drop`, | |||
| 119 | + | `ip daddr 10.200.0.1 tcp dport 53 iifname != "ertap0" drop`, | |||
| 120 | + | `ip daddr 10.200.0.5 udp dport 53 iifname != "ertap1" drop`, | |||
| 121 | + | `ip daddr 10.200.0.5 tcp dport 53 iifname != "ertap1" drop`, | |||
| 122 | + | } { | |||
| 123 | + | if !strings.Contains(rs, want) { | |||
| 124 | + | t.Errorf("lacks %q", want) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // S10: documentation, test-net and benchmarking ranges are blocked too. | |||
| 130 | + | func TestBlockedSetCoversSpecialUseRanges(t *testing.T) { | |||
| 131 | + | rs := Ruleset([]Guest{guest(t, "ertap0", 0)}) | |||
| 132 | + | for _, r := range []string{"192.0.0.0/24", "192.0.2.0/24", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4"} { | |||
| 133 | + | if !strings.Contains(rs, r) { | |||
| 134 | + | t.Errorf("blocked set lacks %s", r) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 81 | func TestSubnet(t *testing.T) { | 139 | func TestSubnet(t *testing.T) { | ||
| 82 | base := netip.MustParsePrefix("10.200.0.0/16") | 140 | base := netip.MustParsePrefix("10.200.0.0/16") | ||
| 83 | n, err := Subnet(base, 3) | 141 | n, err := Subnet(base, 3) | ||
M internal/netpolicy/testdata/one.nft +11 -5
| @@ -4,26 +4,32 @@ table inet ermite_runner { | |||
| 4 | 4 | set blocked_v4 { | |
| 5 | 5 | type ipv4_addr | |
| 6 | 6 | flags interval | |
| 7 | - | elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4 } | |
| 7 | + | elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 } | |
| 8 | 8 | } | |
| 9 | 9 | chain input { | |
| 10 | 10 | type filter hook input priority filter; policy accept; | |
| 11 | 11 | iifname "ertap*" meta nfproto ipv6 drop | |
| 12 | 12 | iifname "erbr*" meta nfproto ipv6 drop | |
| 13 | + | ip daddr 10.200.0.1 udp dport 53 iifname != "ertap0" drop | |
| 14 | + | ip daddr 10.200.0.1 tcp dport 53 iifname != "ertap0" drop | |
| 13 | 15 | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 udp dport 53 accept | |
| 14 | 16 | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 53 accept | |
| 15 | 17 | iifname "ertap0" drop | |
| 18 | + | iifname "ertap*" drop | |
| 19 | + | iifname "erbr*" drop | |
| 16 | 20 | } | |
| 17 | 21 | chain forward { | |
| 18 | 22 | type filter hook forward priority filter; policy accept; | |
| 19 | - | oifname "ertap*" ct state established,related accept | |
| 20 | - | oifname "ertap*" drop | |
| 21 | - | oifname "erbr*" ct state established,related accept | |
| 22 | - | oifname "erbr*" drop | |
| 23 | 23 | iifname "ertap*" meta nfproto ipv6 drop | |
| 24 | 24 | iifname "erbr*" meta nfproto ipv6 drop | |
| 25 | 25 | iifname "ertap0" ip saddr != 10.200.0.2 drop | |
| 26 | 26 | iifname "ertap0" ip daddr @blocked_v4 drop | |
| 27 | + | iifname != { "ertap*", "erbr*" } oifname { "ertap*", "erbr*" } ct state established,related accept | |
| 28 | + | oifname "ertap*" drop | |
| 29 | + | oifname "erbr*" drop | |
| 30 | + | iifname "ertap0" accept | |
| 31 | + | iifname "ertap*" drop | |
| 32 | + | iifname "erbr*" drop | |
| 27 | 33 | } | |
| 28 | 34 | chain postrouting { | |
| 29 | 35 | type nat hook postrouting priority srcnat; policy accept; | |
| @@ -4,26 +4,32 @@ table inet ermite_runner { | |||||
| 4 | set blocked_v4 { | 4 | set blocked_v4 { | ||
| 5 | type ipv4_addr | 5 | type ipv4_addr | ||
| 6 | flags interval | 6 | flags interval | ||
| 7 | - | elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4 } | 7 | + | elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 } |
| 8 | } | 8 | } | ||
| 9 | chain input { | 9 | chain input { | ||
| 10 | type filter hook input priority filter; policy accept; | 10 | type filter hook input priority filter; policy accept; | ||
| 11 | iifname "ertap*" meta nfproto ipv6 drop | 11 | iifname "ertap*" meta nfproto ipv6 drop | ||
| 12 | iifname "erbr*" meta nfproto ipv6 drop | 12 | iifname "erbr*" meta nfproto ipv6 drop | ||
| 13 | + | ip daddr 10.200.0.1 udp dport 53 iifname != "ertap0" drop | |||
| 14 | + | ip daddr 10.200.0.1 tcp dport 53 iifname != "ertap0" drop | |||
| 13 | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 udp dport 53 accept | 15 | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 udp dport 53 accept | ||
| 14 | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 53 accept | 16 | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 53 accept | ||
| 15 | iifname "ertap0" drop | 17 | iifname "ertap0" drop | ||
| 18 | + | iifname "ertap*" drop | |||
| 19 | + | iifname "erbr*" drop | |||
| 16 | } | 20 | } | ||
| 17 | chain forward { | 21 | chain forward { | ||
| 18 | type filter hook forward priority filter; policy accept; | 22 | type filter hook forward priority filter; policy accept; | ||
| 19 | - | oifname "ertap*" ct state established,related accept | |||
| 20 | - | oifname "ertap*" drop | |||
| 21 | - | oifname "erbr*" ct state established,related accept | |||
| 22 | - | oifname "erbr*" drop | |||
| 23 | iifname "ertap*" meta nfproto ipv6 drop | 23 | iifname "ertap*" meta nfproto ipv6 drop | ||
| 24 | iifname "erbr*" meta nfproto ipv6 drop | 24 | iifname "erbr*" meta nfproto ipv6 drop | ||
| 25 | iifname "ertap0" ip saddr != 10.200.0.2 drop | 25 | iifname "ertap0" ip saddr != 10.200.0.2 drop | ||
| 26 | iifname "ertap0" ip daddr @blocked_v4 drop | 26 | iifname "ertap0" ip daddr @blocked_v4 drop | ||
| 27 | + | iifname != { "ertap*", "erbr*" } oifname { "ertap*", "erbr*" } ct state established,related accept | |||
| 28 | + | oifname "ertap*" drop | |||
| 29 | + | oifname "erbr*" drop | |||
| 30 | + | iifname "ertap0" accept | |||
| 31 | + | iifname "ertap*" drop | |||
| 32 | + | iifname "erbr*" drop | |||
| 27 | } | 33 | } | ||
| 28 | chain postrouting { | 34 | chain postrouting { | ||
| 29 | type nat hook postrouting priority srcnat; policy accept; | 35 | type nat hook postrouting priority srcnat; policy accept; | ||
M internal/netpolicy/testdata/two.nft +14 -5
| @@ -4,31 +4,40 @@ table inet ermite_runner { | |||
| 4 | 4 | set blocked_v4 { | |
| 5 | 5 | type ipv4_addr | |
| 6 | 6 | flags interval | |
| 7 | - | elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4 } | |
| 7 | + | elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 } | |
| 8 | 8 | } | |
| 9 | 9 | chain input { | |
| 10 | 10 | type filter hook input priority filter; policy accept; | |
| 11 | 11 | iifname "ertap*" meta nfproto ipv6 drop | |
| 12 | 12 | iifname "erbr*" meta nfproto ipv6 drop | |
| 13 | + | ip daddr 10.200.0.5 udp dport 53 iifname != "erbr1" drop | |
| 14 | + | ip daddr 10.200.0.5 tcp dport 53 iifname != "erbr1" drop | |
| 15 | + | ip daddr 10.200.0.1 udp dport 53 iifname != "ertap0" drop | |
| 16 | + | ip daddr 10.200.0.1 tcp dport 53 iifname != "ertap0" drop | |
| 13 | 17 | iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 udp dport 53 accept | |
| 14 | 18 | iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 tcp dport 53 accept | |
| 15 | 19 | iifname "erbr1" drop | |
| 16 | 20 | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 udp dport 53 accept | |
| 17 | 21 | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 53 accept | |
| 18 | 22 | iifname "ertap0" drop | |
| 23 | + | iifname "ertap*" drop | |
| 24 | + | iifname "erbr*" drop | |
| 19 | 25 | } | |
| 20 | 26 | chain forward { | |
| 21 | 27 | type filter hook forward priority filter; policy accept; | |
| 22 | - | oifname "ertap*" ct state established,related accept | |
| 23 | - | oifname "ertap*" drop | |
| 24 | - | oifname "erbr*" ct state established,related accept | |
| 25 | - | oifname "erbr*" drop | |
| 26 | 28 | iifname "ertap*" meta nfproto ipv6 drop | |
| 27 | 29 | iifname "erbr*" meta nfproto ipv6 drop | |
| 28 | 30 | iifname "erbr1" ip saddr != 10.200.0.6 drop | |
| 29 | 31 | iifname "erbr1" ip daddr @blocked_v4 drop | |
| 30 | 32 | iifname "ertap0" ip saddr != 10.200.0.2 drop | |
| 31 | 33 | iifname "ertap0" ip daddr @blocked_v4 drop | |
| 34 | + | iifname != { "ertap*", "erbr*" } oifname { "ertap*", "erbr*" } ct state established,related accept | |
| 35 | + | oifname "ertap*" drop | |
| 36 | + | oifname "erbr*" drop | |
| 37 | + | iifname "erbr1" accept | |
| 38 | + | iifname "ertap0" accept | |
| 39 | + | iifname "ertap*" drop | |
| 40 | + | iifname "erbr*" drop | |
| 32 | 41 | } | |
| 33 | 42 | chain postrouting { | |
| 34 | 43 | type nat hook postrouting priority srcnat; policy accept; | |
| @@ -4,31 +4,40 @@ table inet ermite_runner { | |||||
| 4 | set blocked_v4 { | 4 | set blocked_v4 { | ||
| 5 | type ipv4_addr | 5 | type ipv4_addr | ||
| 6 | flags interval | 6 | flags interval | ||
| 7 | - | elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4 } | 7 | + | elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 } |
| 8 | } | 8 | } | ||
| 9 | chain input { | 9 | chain input { | ||
| 10 | type filter hook input priority filter; policy accept; | 10 | type filter hook input priority filter; policy accept; | ||
| 11 | iifname "ertap*" meta nfproto ipv6 drop | 11 | iifname "ertap*" meta nfproto ipv6 drop | ||
| 12 | iifname "erbr*" meta nfproto ipv6 drop | 12 | iifname "erbr*" meta nfproto ipv6 drop | ||
| 13 | + | ip daddr 10.200.0.5 udp dport 53 iifname != "erbr1" drop | |||
| 14 | + | ip daddr 10.200.0.5 tcp dport 53 iifname != "erbr1" drop | |||
| 15 | + | ip daddr 10.200.0.1 udp dport 53 iifname != "ertap0" drop | |||
| 16 | + | ip daddr 10.200.0.1 tcp dport 53 iifname != "ertap0" drop | |||
| 13 | iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 udp dport 53 accept | 17 | iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 udp dport 53 accept | ||
| 14 | iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 tcp dport 53 accept | 18 | iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 tcp dport 53 accept | ||
| 15 | iifname "erbr1" drop | 19 | iifname "erbr1" drop | ||
| 16 | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 udp dport 53 accept | 20 | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 udp dport 53 accept | ||
| 17 | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 53 accept | 21 | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 53 accept | ||
| 18 | iifname "ertap0" drop | 22 | iifname "ertap0" drop | ||
| 23 | + | iifname "ertap*" drop | |||
| 24 | + | iifname "erbr*" drop | |||
| 19 | } | 25 | } | ||
| 20 | chain forward { | 26 | chain forward { | ||
| 21 | type filter hook forward priority filter; policy accept; | 27 | type filter hook forward priority filter; policy accept; | ||
| 22 | - | oifname "ertap*" ct state established,related accept | |||
| 23 | - | oifname "ertap*" drop | |||
| 24 | - | oifname "erbr*" ct state established,related accept | |||
| 25 | - | oifname "erbr*" drop | |||
| 26 | iifname "ertap*" meta nfproto ipv6 drop | 28 | iifname "ertap*" meta nfproto ipv6 drop | ||
| 27 | iifname "erbr*" meta nfproto ipv6 drop | 29 | iifname "erbr*" meta nfproto ipv6 drop | ||
| 28 | iifname "erbr1" ip saddr != 10.200.0.6 drop | 30 | iifname "erbr1" ip saddr != 10.200.0.6 drop | ||
| 29 | iifname "erbr1" ip daddr @blocked_v4 drop | 31 | iifname "erbr1" ip daddr @blocked_v4 drop | ||
| 30 | iifname "ertap0" ip saddr != 10.200.0.2 drop | 32 | iifname "ertap0" ip saddr != 10.200.0.2 drop | ||
| 31 | iifname "ertap0" ip daddr @blocked_v4 drop | 33 | iifname "ertap0" ip daddr @blocked_v4 drop | ||
| 34 | + | iifname != { "ertap*", "erbr*" } oifname { "ertap*", "erbr*" } ct state established,related accept | |||
| 35 | + | oifname "ertap*" drop | |||
| 36 | + | oifname "erbr*" drop | |||
| 37 | + | iifname "erbr1" accept | |||
| 38 | + | iifname "ertap0" accept | |||
| 39 | + | iifname "ertap*" drop | |||
| 40 | + | iifname "erbr*" drop | |||
| 32 | } | 41 | } | ||
| 33 | chain postrouting { | 42 | chain postrouting { | ||
| 34 | type nat hook postrouting priority srcnat; policy accept; | 43 | type nat hook postrouting priority srcnat; policy accept; | ||