summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCalvin Morrison <calvin@pobox.com>2026-08-18 21:02:41 -0400
committerCalvin Morrison <calvin@pobox.com>2026-08-18 21:02:41 -0400
commitc2c6a9eb419ebfc0ea5cac25e199362da25f1013 (patch)
tree4834f69e2e372836d8814d2ff788ea5ee956cfbe
parent978b9cde1b7f448db5816a0d15fe9f72a94e3341 (diff)
netfs: name what is served instead of listing what is hidden
/net/tcp/trans, /net/udp/trans and /net/icmp/trans install kernel address translations. devip gates them with iseve() (devip.c:406), and through this server that is fw's identity, not the caller's -- fw does every open with its own credentials and never looks at the client's. On a machine where fw runs as eve, which is the ordinary case, there was no gate at all. Demonstrated in a sandbox with an empty rule set: === baseline: real /net, no fw === echo: write error: local ip not found === inside the sandbox === connect: refused (as expected) append via fw: local ip not found create via fw: bad process or channel control request Both errors come from transwrite itself, so the open succeeded and fw imposed nothing; and the second proves the OTRUNC path is reachable, which runs transwrite(p, nil, 0, 0) and flushes the whole table before the write is even parsed. /net/log was half closed: the write was refused so a program could not turn tracing on, but reading it was the leak, and anything an administrator turns on elsewhere is then readable from inside the sandbox. ipifc data was refused rather than hidden, against the principle stated ten lines above it for ether and ipmux, and its snoop file is the same wire and was not mentioned at all. The pattern is the problem. A list of things to deny has now been wrong twice, in the same way the ctl filter was, and the answer is the one that worked there: nothing is served unless it is named. Protocol directories come from a list of names rather than from "has a clone file", because devether has one of those too and #l bound into /net would have become a protocol; a protocol missing from the list is one nobody can reach, which is the safe way to be out of date. Within one, only clone, stats and the conversation files, and for ipifc not clone, not data, not snoop. In the root, only cs and dns writable and arp, bootp, iproute, ipselftab and ndb readable. Splitting the path also disposes of a name like "tcp/../.." arriving as a single walk element from a client speaking 9P straight to the server: more than three components, or an empty one, is not a path this server handed out, so it is not one it will honour. Sixteen new checks. Against the previous netfs.c six of them fail -- trans served, log served, ipifc data and snoop served, and both listing checks -- while cs, arp, ndb, iproute, ipifc status, clone and connect filtering all still pass, which is the half that matters. They ask by stat rather than by read: reading log or a data file blocks until traffic arrives, so reading would hang on exactly the build that still serves them, and a test that hangs on a regression is worse than none. 46 pass, twice in a row with no cleanup between. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--fw/src/netfs.c223
-rwxr-xr-xfw/test/fwtest.rc68
2 files changed, 237 insertions, 54 deletions
diff --git a/fw/src/netfs.c b/fw/src/netfs.c
index f28485f..4aa1909 100644
--- a/fw/src/netfs.c
+++ b/fw/src/netfs.c
@@ -95,65 +95,123 @@ childpath(char *dir, char *name)
return smprint("%s/%s", dir, name);
}
-/* does path name this top-level entry, or something under it? */
-static int
-under(char *path, char *name)
-{
- int n;
-
- n = strlen(name);
- return strncmp(path, name, n) == 0 && (path[n] == '\0' || path[n] == '/');
-}
+/*
+ * What is served, named; not what is hidden, listed.
+ *
+ * Listing what to hide has now been wrong twice. "trans" installs
+ * kernel address translations and devip gates it with iseve(), which is
+ * fw's identity here and not the caller's, so a filtered program could
+ * write it and reach the whole machine. "log" is a trace of every
+ * connection on the machine, and only its write was refused, when
+ * reading it was the leak. Neither was a hard problem: both were
+ * simply missing from a list of things to deny.
+ *
+ * So it is the other way round. Nothing is served unless it is named
+ * below, and the next file devip grows is invisible until someone
+ * decides otherwise - which is the direction a firewall should fail in,
+ * and the same argument the rule parser makes about attributes it does
+ * not recognise.
+ */
/*
- * Raw packet access. Kept out of the served tree entirely rather than
- * made unopenable, so that a program probing for a way out does not
- * even see one.
+ * devip's protocol directories, by name. Not "any directory with a
+ * clone file": devether has one of those too, and #l bound into /net
+ * would have become a protocol. A protocol missing from this list is
+ * one nobody can reach, which is the safe way to be out of date.
+ * ipmux is left out on purpose - it is raw packet access.
*/
+static char *protos[] = {
+ "tcp", "udp", "il", "icmp", "icmpv6", "rudp", "gre", "esp",
+ "ipifc", /* read-only: see writable() */
+ nil,
+};
+
+/* the files a conversation directory has */
+static char *convfiles[] = {
+ "ctl", "data", "err", "listen", "local", "remote", "status",
+ nil,
+};
+
+/* of those, the ones a filtered program may write */
+static char *rwconvfiles[] = {
+ "ctl", "data", "err", "listen",
+ nil,
+};
+
+/* everything else served from the root, and whether writing it is allowed */
+static struct {
+ char *name;
+ int rw;
+} rootfiles[] = {
+ { "cs", 1 }, /* asking for a translation is a write */
+ { "dns", 1 },
+ { "arp", 0 },
+ { "bootp", 0 },
+ { "iproute", 0 },
+ { "ipselftab", 0 },
+ { "ndb", 0 },
+};
+
static int
-hidden(char *path)
+inlist(char **l, char *s)
{
- if(strncmp(path, "ether", 5) == 0 && path[5] >= '0' && path[5] <= '9')
- return 1;
- return under(path, "ipmux");
+ int i;
+
+ for(i = 0; l[i] != nil; i++)
+ if(strcmp(l[i], s) == 0)
+ return 1;
+ return 0;
}
-static char*
-protect(char *path, int mode)
+static int
+rootfile(char *name)
{
- char *p;
+ int i;
- if(hidden(path))
- return "fw: does not exist";
- if(strcmp(path, "ipifc/clone") == 0)
- return "fw: interface creation denied";
+ for(i = 0; i < nelem(rootfiles); i++)
+ if(strcmp(rootfiles[i].name, name) == 0)
+ return i;
+ return -1;
+}
- /*
- * An interface's data file is a wire. On a machine that is also
- * running fw -e it is *the* wire, so reading it hands a filtered
- * program the packet stream the filter exists to control. Not
- * read-only: not at all.
- */
- if(under(path, "ipifc"))
- if((p = strrchr(path, '/')) != nil && strcmp(p+1, "data") == 0)
- return "fw: denied";
+static int
+isnum(char *s)
+{
+ if(*s == '\0')
+ return 0;
+ for(; *s != '\0'; s++)
+ if(*s < '0' || *s > '9')
+ return 0;
+ return 1;
+}
- /*
- * ndb is the machine's configuration and log is a trace of every
- * connection on it, both mode 0666. Writing the first
- * reconfigures name service for everyone; reading the second
- * after enabling it watches the whole machine.
- */
- if(under(path, "ipifc") || under(path, "iproute") || under(path, "arp")
- || under(path, "ndb") || under(path, "log"))
- if((mode & 3) != OREAD)
- return "fw: read-only under fw";
- return nil;
+/*
+ * Split a path into its components, of which a served path has at most
+ * three. A deeper path, an over-long one, or one with an empty
+ * component is not a path this server ever handed out, so it is not one
+ * it will honour: that also disposes of names like "tcp/../.." arriving
+ * as a single walk element from a client speaking 9P directly.
+ */
+static int
+splitpath(char *path, char *buf, int nbuf, char **f)
+{
+ int i, n;
+
+ if(strlen(path) >= nbuf)
+ return -1;
+ strcpy(buf, path);
+ n = getfields(buf, f, 4, 0, "/");
+ if(n < 1 || n > 3)
+ return -1;
+ for(i = 0; i < n; i++)
+ if(*f[i] == '\0')
+ return -1;
+ return n;
}
/*
- * A directory in the root is a protocol directory if it has a clone
- * file. Asking the filesystem beats hardcoding a list that goes stale.
+ * A protocol this kernel actually has. The name list says which ones
+ * may be served; the clone file says which ones are there.
*/
static int
isproto(char *name)
@@ -161,7 +219,7 @@ isproto(char *name)
char *p;
int ok;
- if(strchr(name, '/') != nil || *name == '\0')
+ if(!inlist(protos, name))
return 0;
p = smprint("%s/%s/clone", orig, name);
ok = access(p, AEXIST) == 0;
@@ -169,6 +227,75 @@ isproto(char *name)
return ok;
}
+static int
+served(char *path)
+{
+ char buf[128], *f[4];
+ int n;
+
+ if(*path == '\0')
+ return 1; /* the root */
+ if((n = splitpath(path, buf, sizeof buf, f)) < 0)
+ return 0;
+
+ if(!isproto(f[0]))
+ return n == 1 && rootfile(f[0]) >= 0;
+ if(n == 1)
+ return 1; /* the protocol directory */
+
+ if(isnum(f[1])){
+ if(n == 2)
+ return 1; /* a conversation directory */
+ /*
+ * An interface's data and snoop files are a wire. On a
+ * machine also running fw -e one of them is *the* wire, so
+ * either hands a filtered program the packet stream the
+ * filter exists to control.
+ */
+ if(strcmp(f[0], "ipifc") == 0)
+ if(strcmp(f[2], "data") == 0 || strcmp(f[2], "snoop") == 0)
+ return 0;
+ return inlist(convfiles, f[2]);
+ }
+ if(n != 2)
+ return 0;
+ if(strcmp(f[0], "ipifc") == 0)
+ return strcmp(f[1], "stats") == 0; /* no new interfaces */
+ return strcmp(f[1], "clone") == 0 || strcmp(f[1], "stats") == 0;
+}
+
+static int
+writable(char *path)
+{
+ char buf[128], *f[4];
+ int n, i;
+
+ if((n = splitpath(path, buf, sizeof buf, f)) < 0)
+ return 0;
+ if(!isproto(f[0])){
+ if(n != 1 || (i = rootfile(f[0])) < 0)
+ return 0;
+ return rootfiles[i].rw;
+ }
+ if(strcmp(f[0], "ipifc") == 0)
+ return 0; /* the interfaces are not ours to change */
+ if(n == 2)
+ return strcmp(f[1], "clone") == 0;
+ if(n == 3 && isnum(f[1]))
+ return inlist(rwconvfiles, f[2]);
+ return 0;
+}
+
+static char*
+protect(char *path, int mode)
+{
+ if(!served(path))
+ return "fw: does not exist";
+ if((mode & 3) != OREAD && !writable(path))
+ return "fw: read-only under fw";
+ return nil;
+}
+
/*
* If path is a protocol ctl file - "tcp/clone" or "tcp/1/ctl" - return
* the protocol name. Opening clone yields an fd that is itself the new
@@ -341,7 +468,7 @@ slurpdir(Fnode *f, char *rp)
sz = 0;
for(i = 0; i < n; i++){
cp = childpath(f->path, d[i].name);
- if(hidden(cp)){
+ if(!served(cp)){
free(cp);
continue;
}
@@ -428,7 +555,7 @@ fswalk1(Fid *fid, char *name, Qid *q)
f = fid->aux;
np = childpath(f->path, name);
- if(hidden(np)){
+ if(!served(np)){
free(np);
return "fw: does not exist";
}
diff --git a/fw/test/fwtest.rc b/fw/test/fwtest.rc
index 0b923e9..a7ba4d2 100755
--- a/fw/test/fwtest.rc
+++ b/fw/test/fwtest.rc
@@ -104,6 +104,19 @@ fn have {
echo no
}
+# Is it gone from the served tree?
+#
+# A stat, not a read. Reading /net/log, or an interface's data or snoop
+# file, blocks until traffic arrives, so a check that read them would
+# hang rather than fail on exactly the build that still serves them -
+# and a test that hangs on a regression is worse than no test.
+fn gone {
+ if(test -e $1)
+ echo there
+ if not
+ echo gone
+}
+
# Stop the firewalls a packet check started.
#
# Take the interfaces away and fw follows: the relay's read fails and
@@ -139,7 +152,8 @@ cat > $tmp/empty.ndb <<'!'
!
echo '== namespace mode: what is refused is there to refuse'
-for(p in /net/udp/clone /net/gre/clone /net/ndb /net/log /net/ipifc/0/data){
+for(p in /net/udp/clone /net/gre/clone /net/ndb /net/log /net/ipifc/0/data \
+ /net/ipifc/0/snoop /net/tcp/trans /net/ether0/clone /net/ipmux/clone){
r=`{have $p}
check 'the real /net has '^$p yes $"r
}
@@ -164,13 +178,55 @@ echo '== namespace mode'
r=`{wr /net/ndb 'x'}
check '/net/ndb is not writable' refused $"r
- r=`{wr /net/log 'tcp'}
- check '/net/log is not writable' refused $"r
-
- r=`{rd /net/ipifc/0/data}
- check 'an interface data file is not readable' refused $"r
+ # trans installs a kernel address translation and devip gates it
+ # with iseve() -- which is fw's identity through here, not the
+ # caller's, so on a machine where fw runs as eve there was no gate
+ # at all. Opening it with truncation also flushed the table.
+ r=`{gone /net/tcp/trans}
+ check 'a protocol trans file is not served' gone $"r
+ r=`{wr /net/tcp/trans '10.9.9.9 80 10.9.9.8 1234 10.9.9.7 5678'}
+ check 'and cannot be written' refused $"r
+
+ # reading log is the leak, not writing it: turn it on elsewhere and
+ # it traces every connection on the machine
+ r=`{gone /net/log}
+ check '/net/log is not served at all' gone $"r
+
+ r=`{gone /net/ipifc/0/data}
+ check 'an interface data file is a wire, and is not served' gone $"r
+ r=`{gone /net/ipifc/0/snoop}
+ check 'nor is its snoop file, which is the same wire' gone $"r
r=`{rd /net/ipifc/0/status}
check 'but its status still is' ok $"r
+
+ r=`{gone /net/ether0/clone}
+ check 'a card is not served, clone file or no' gone $"r
+ r=`{gone /net/ipmux/clone}
+ check 'nor is ipmux' gone $"r
+
+ # The mount driver splits a path before it sends it, so this only
+ # asks that the obvious way out is shut; a compound name arriving
+ # as one walk element needs a client speaking 9P straight to the
+ # server, and splitpath is what refuses that.
+ r=`{gone '/net/tcp/../../adm/keys'}
+ check 'nothing outside /net resolves through it' gone $"r
+
+ # what a program still needs
+ r=`{wr /net/cs 'tcp!10.9.9.9!80'}
+ check 'cs still translates' ok $"r
+ r=`{rd /net/arp}
+ check 'arp is still readable' ok $"r
+ r=`{rd /net/ndb}
+ check 'ndb is still readable' ok $"r
+ r=`{rd /net/iproute}
+ check 'iproute is still readable' ok $"r
+
+ r=`{ls -p /net | grep -c '^(log|ipmux|ether0|ether1)$'}
+ check 'none of them are even listed' 0 $"r
+ r=`{ls -p /net/tcp | grep -c '^trans$'}
+ check 'and trans is not listed either' 0 $"r
+ r=`{ls -p /net/tcp | grep -c '^clone$'}
+ check 'while clone still is' 1 $"r
}
@{