Skip to content

Commit 3ec5979

Browse files
authored
Merge pull request #19 from ausimian/issue/13-private-dir
fix: create the peer's configuration files where nothing else can reach them
2 parents 37ae759 + 236d517 commit 3ec5979

4 files changed

Lines changed: 837 additions & 171 deletions

File tree

AGENTS.md

Lines changed: 189 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -57,58 +57,150 @@ Castle's job is configuration and release management on a running node.
5757
result has to land in; the first materialisation therefore copies it to
5858
`sys.config.pristine` and every later one seeds from there.
5959

60-
That copy is staged under a name of its own, given the mode `sys.config` has,
61-
and published by hard link. Not written to its final name, and an exclusive
62-
create is not enough either: exclusivity makes *creation* atomic, not
63-
publication, so the file exists and is empty between the open and the write —
64-
long enough for a racing reader to see something that is not a configuration,
65-
and, if the install died there, long enough to leave a truncated base that
66-
every later evaluation would prefer to the original still in `sys.config`. A
67-
link publishes a file that is already complete, and refuses rather than
68-
replaces, so the loser of a race reads what the winner published instead of
69-
its own copy. Staging that never gets published is left where it is: an
70-
install cannot tell its own leftovers from another install's work in progress,
71-
so it does not try, and nothing reads that name. Do not "tidy up" stray
72-
`castle-*.pristine` files in code for the same reason.
73-
74-
**Every file this module creates comes into existence through
75-
`Castle.Peer.write_private/2`, and new ones must too.** Each of them holds a
60+
That copy is staged in the working directory below, given the mode
61+
`sys.config` has, and published by hard link. Not written to its final name,
62+
and an exclusive create is not enough either: exclusivity makes *creation*
63+
atomic, not publication, so the file exists and is empty between the open and
64+
the write — long enough for a racing reader to see something that is not a
65+
configuration, and, if the install died there, long enough to leave a truncated
66+
base that every later evaluation would prefer to the original still in
67+
`sys.config`. A link publishes a file that is already complete, and refuses
68+
rather than replaces, so the loser of a race reads what the winner published
69+
instead of its own copy. Staging that never gets published is left where it is:
70+
an install cannot tell its own leftovers from another install's work in
71+
progress, so it does not try, and nothing reads that name. Do not "tidy up"
72+
stray `castle-*` names — files or directories — in code for the same reason.
73+
`Castle.Peer` removes the working directory *it* made, on every way out, and
74+
nothing else.
75+
76+
**Both files this module creates are made inside an owner-only working
77+
directory and moved out of it, and a new one must be too.** Each holds a
7678
release's configuration — the base, and the scratch copy the providers resolve
77-
into — so none of them may be readable by anyone the `sys.config` it came from
78-
or is about to become would exclude: an operator who restricts that file has
79-
said something, and it has to hold for the copies.
80-
81-
The file is created owner-only at 0600, filled through that, and given the
82-
model's mode **last**`write_like/3` is the two of those together, for a
83-
file written once. Not the other way round, which is the obvious reading and
84-
is wrong: a `sys.config` at 0440 is an operator declaring their configuration
85-
read-only, and a file chmodded to 0440 before being filled cannot be filled.
86-
`File.write/2` reopens the path rather than writing through a handle held from
87-
creation, so the fill fails `:eacces` against a file its own owner has just
88-
made read-only, and the install stops. Do not "simplify" the ordering back.
89-
90-
0600 satisfies both constraints at once rather than trading between them: it
91-
grants nothing to group or other, so the transient state is *narrower* than
92-
the destination rather than merely different from it, and it leaves the file
93-
writable by its owner while there is writing to do. For the scratch that is
94-
until the last of three writes — this module fills it, the peer's pipeline
95-
writes the resolved configuration over it, this module writes it again — so
96-
the mode goes on after all of them, immediately before the rename. A failure
97-
part-way leaves the file narrower than intended, never wider. The two
98-
operations that move one of these files into place, the link that publishes
99-
the base and the rename that replaces `sys.config`, need permission on the
100-
directory rather than on the file, so a restrictive mode never has to be
101-
relaxed again.
102-
103-
The ordering lives inside the primitive rather than at the call sites because
104-
remembering it at the call sites is what failed, three times. `File.write/2`
105-
creates with the process umask and never looks at a mode. `File.cp/2` *does*
106-
carry the mode — and was adopted here for that reason — but it writes the whole
107-
file first and narrows it afterwards (`:file.copy`, then `copy_file_mode/2` at
108-
`file.ex:1285`), which is the same exposure with a shorter window. The end
109-
state is identical either way, which is exactly why no test of the end state
110-
caught it. Do not add a fourth way to write one of these files; extend the
111-
primitive.
79+
into — so none may be readable by anyone the `sys.config` it came from or is
80+
about to become would exclude: an operator who restricts that file has said
81+
something, and it has to hold for the copies.
82+
83+
**The protection is the directory, not the file's own mode, and it has to be:
84+
OTP cannot create a file with a mode.** `:file.open/2`'s modes say how a file
85+
is to be read and written and nothing about the permissions it is created
86+
with — kernel's `mode()` type is the whole list — and an unrecognised option is
87+
ignored rather than refused, so `{:mode, 0o600}` is *accepted* and does
88+
nothing. The inode is created 0666 against the umask whichever way in you
89+
take. So a mode can only be applied to a file that already exists, and `chmod`
90+
does not revoke a descriptor somebody already holds: a reader who opened the
91+
path while it was 0644 goes on reading everything written afterwards. That is
92+
a standing read channel, not a blink, and no amount of care at the call site
93+
closes it. Do not go looking for an atomic create-with-mode; there isn't one.
94+
95+
What works is `Castle.Peer.work_dir/1`: `mkdir` a directory in the version
96+
directory, chmod it 0700, and then **check that it is still empty**, refusing
97+
and removing it if it is not. `plan/2` is the one place the two file paths are
98+
decided, and it puts both inside that directory under the names they will have
99+
when they leave. Each is created **exclusively**, so a name already at the
100+
path is refused rather than followed or truncated, and each is **written
101+
through the handle that exclusive open returned**.
102+
103+
**Never reopen a name to place content.** The exclusive open's whole value is
104+
that it establishes the name did not exist; closing the handle and reopening
105+
the same name by path throws that away, and anything able to create the name
106+
in between is handed the configuration. `write_private/2` therefore creates
107+
and fills in one movement — there is deliberately no way here to bring one of
108+
these files into existence without also placing its content, because
109+
separating the two is what let a reopen back in.
110+
111+
The `chmod` is the one step that still goes by path, because OTP has nothing
112+
that sets a mode on an open file: `:file.change_mode/2` and
113+
`:file.write_file_info/2` take a name and reject a handle (`:badarg` and
114+
`:function_clause`). That is an accepted asymmetry rather than an oversight.
115+
By the time it runs the content is already committed to the inode the open
116+
created, so a name swapped underneath it does not receive the configuration —
117+
it gets narrowed, which is Castle setting some other file to 0600 inside a
118+
directory it verified empty and made 0700. A nuisance, not a disclosure.
119+
120+
**The rule is create, narrow, then verify — and write through the handle you
121+
created.** The verification is not decoration and it is not defence in depth:
122+
it is there because five successive attempts to reason about whether a window
123+
was harmless were all wrong, and a sixth judgement of the same kind is worth
124+
nothing. Do not remove it on the grounds that you can see why the window is
125+
safe. That is precisely the sentence that preceded each of the previous five
126+
findings.
127+
128+
The particular argument it replaced, so nobody reconstructs it: an empty
129+
directory has nothing behind the window, and permission to traverse a
130+
directory is checked on every lookup rather than captured at open the way
131+
permission to read a file is, so a stale directory descriptor grants nothing
132+
once the chmod has happened. Both halves are true, and both are about
133+
*reading*. A directory the umask left group-writable — 0002 is an ordinary
134+
umask and 0000 exists — can be written *into* during that window, and the child
135+
names are predictable, so an interloper needs no descriptor at all: it plants
136+
`sys.config` as a symlink to a file it can read and waits for the
137+
configuration to arrive through it. Empty is safe to read. It is not safe to
138+
write into.
139+
140+
Exclusivity and privacy are separate properties and neither substitutes for
141+
the other. `:exclusive` on the open says nothing about the permissions the
142+
inode arrives with, which is why it is no answer to the paragraph above — that
143+
was measured, and `{:mode, _}` alongside it is silently ignored. A private
144+
directory says nothing about what a name already inside it would do, which is
145+
why it is no answer to a planted symlink: `File.write/2` follows one, truncates
146+
what it points at, chmods *that* to 0600, fills it with the configuration, and
147+
creates the target outright if the link dangles. All measured, all refused by
148+
`:exclusive`, which returns `:eexist` for a regular file, a symlink and a
149+
dangling symlink alike. `File.mkdir/1` refuses all three too, which is what
150+
stops the working directory's own name being taken first.
151+
152+
It does not defend a version directory other accounts can write to: whoever
153+
can create a name there can replace `sys.config` itself, so that release is
154+
compromised before Castle is asked to configure it. The case defended is the
155+
ordinary one, a version directory anyone may traverse and read.
156+
157+
`write_private/2` **refuses** to create a file in a directory that grants
158+
anything to group or other, rather than trusting the caller to have picked a
159+
path inside the working directory — the invariant is in the primitive because
160+
remembering it at the call sites is what failed, four times over. It is a
161+
guard against the next call site rather than against an attacker — a directory
162+
can be chmodded between the check and the create, which is the gap the
163+
exclusive create covers — and it also catches a filesystem that took the
164+
`mkdir` and ignored the `chmod`, where none of this can be honoured and the
165+
operator's own mode on `sys.config` would not be either.
166+
167+
The file is given 0600 on creation and the model's mode **last**
168+
`write_like/3` is `write_private/2` plus that, for a file written once. The
169+
ordering matters for the writes that come *after*, not for the first one: a
170+
`sys.config` at 0440 is an operator declaring their configuration read-only,
171+
and the scratch is written twice more after Castle creates it — the peer's
172+
pipeline writes the resolved configuration over it, then this module writes it
173+
again — with both of those reopening the name, and a file at 0440 cannot be
174+
reopened for writing. So the model's mode goes on last of all, immediately
175+
before the rename, and a failure part-way leaves the file narrower than
176+
intended rather than wider. Do not "simplify" the ordering back.
177+
178+
**The scratch's later writes rest on the directory, not on the handle.** One of
179+
them is `Config.Provider.write_config!`, which is `File.write/2` in Elixir's
180+
own code, in the peer's VM — driving Elixir's pipeline is what this module is
181+
for, so that is not ours to change and must not be worked around. Nor may the
182+
creation handle be held open across the peer's run to cover them: the peer
183+
writes by name in a VM of its own, and a handle kept here would go on pointing
184+
at whichever inode the name had when it was opened. Elixir truncates the same
185+
inode today; one that wrote a temporary file and renamed it would leave the
186+
handle on an orphan and the configuration written through it would silently be
187+
nobody's. What protects those writes is that the directory was verified empty,
188+
is 0700, and holds only names Castle created.
189+
190+
The two operations that move one of these files into place, the link that
191+
publishes the base and the rename that replaces `sys.config`, need permission
192+
on the directories rather than on the file, so a restrictive mode never has to
193+
be relaxed again; nor does removing what is left in the working directory
194+
afterwards.
195+
196+
`File.write/2` creates with the process umask and never looks at a mode.
197+
`File.cp/2` *does* carry the mode — and was adopted here for that reason — but
198+
it writes the whole file first and narrows it afterwards (`:file.copy`, then
199+
`copy_file_mode/2` at `file.ex:1285`), which is the same exposure with a
200+
shorter window. The end state is identical either way, which is exactly why no
201+
test of the end state caught any of it. Do not add another way to write one of
202+
these files, and do not create one next to `sys.config` however carefully;
203+
extend the primitive, and put the file in the working directory.
112204

113205
**Ownership and group are not reproduced — only the mode bits are.** This is a
114206
property of the design, not an oversight. Reproducing them needs `chown`,
@@ -154,9 +246,8 @@ Castle's job is configuration and release management on a running node.
154246
never answers cannot hold an install open. Everything that can refuse — a
155247
missing boot script, an emulator that is not there, a provider that raises, a
156248
compile environment that does not agree — refuses before `install_release/1`
157-
is called. The resolved configuration is assembled in a copy beside
158-
`sys.config` and renamed onto it, so a version never holds half a
159-
configuration.
249+
is called. The resolved configuration is assembled in the working directory
250+
and renamed onto `sys.config`, so a version never holds half a configuration.
160251

161252
The control connection is a socket rather than `connection: :standard_io`,
162253
which is what the issue suggested. Standard IO multiplexes the peer's console
@@ -311,24 +402,53 @@ commit — and the other once with the environment as it ended up. The two
311402
`sys.config` terms have to be equal. That is why `Castle.SyntheticRelease` makes
312403
its symlinks idempotently: a root has to be able to hold two versions.
313404

314-
`Castle.Peer.write_like/3`, `write_private/2`, `create_private/1` and
315-
`publish/2` are public for the same kind of reason: what they guarantee is about
316-
*intermediate* states, and a window nothing can stand in is a window nothing can
317-
test. One test takes `write_like/3` and `publish/2` one at a time and looks at
318-
the destination in between — where it finds no file, rather than a partial one —
319-
then checks that publishing again is refused rather than allowed to replace.
320-
Another calls `create_private/1` and finds the file already at 0600, which is the
321-
state that makes the window harmless whatever mode the file ends up with.
405+
`Castle.Peer.work_dir/1`, `secure_dir/1`, `write_like/3`, `write_private/2`,
406+
`create_exclusive/1`, `fill/3` and `publish/2` are public for the same kind of
407+
reason: what they guarantee is about *intermediate* states, and a window nothing
408+
can stand in is a window nothing can test. One test takes `work_dir/1`,
409+
`write_like/3` and `publish/2` one at a time and looks at the destination in
410+
between — where it finds no file, rather than a partial one — then checks that
411+
publishing again is refused rather than allowed to replace. Another calls
412+
`work_dir/1` and finds the directory at 0700 and still empty. Call sites use
413+
`write_private/2`; `create_exclusive/1` and `fill/3` are public only so that the
414+
window between them can be stood in, and never to be called in sequence by
415+
anything else.
416+
417+
`fill/3` being separable is what lets a test swap the name between the exclusive
418+
open and the write, and assert that the content reached the inode that was
419+
created while the file the name now points at never saw it — with that same test
420+
asserting the acknowledged cost, that the by-path `chmod` does land on the
421+
swapped name. With the name left alone the two behaviours are identical, so
422+
there is no other way to tell them apart.
423+
424+
`secure_dir/1` is public so that the `mkdir`-to-`chmod` window can be stood in:
425+
a test creates a directory at 0777, plants a `sys.config` symlink inside it, and
426+
asserts that securing it is refused, the directory removed and what the symlink
427+
pointed at untouched. That is the only way to observe it — nothing about the end
428+
state distinguishes a directory that was empty when it was narrowed from one that
429+
was not, which is what made the same mistake possible a sixth time at the
430+
directory after five at the file. A companion test plants a name inside an
431+
already-private directory and asserts `write_private/2` refuses it rather than
432+
writing through it, which is the half a private directory does not cover.
322433

323434
Those have to be written that way. The mode a file *ends up* with is the same
324435
whether it was set before or after the content, so a test of the end state
325-
passes either way — which is how the exposure survived a round of review that had
326-
already identified the class. The in-peer observation of the scratch file's mode
327-
is a regression guard on the site that was wrong, not a discriminator: it passes
328-
against the version that had the window too. What *is* a discriminator, and the
329-
reason the ordering can no longer be reversed by accident, is the release whose
330-
`sys.config` is 0440: it materialises twice and both files end at 0440, where
331-
setting the mode first fails to write the file at all.
436+
passes either way — which is how the exposure survived two rounds of review that
437+
had already identified the class. The in-peer observation of the scratch file's
438+
mode is a regression guard on the site that was wrong, not a discriminator: it
439+
passes against a version that had the window too.
440+
441+
What *is* a discriminator, and what makes the exposure unreachable rather than
442+
merely narrow, is the in-peer walk of the version directory taken while both
443+
files exist and both hold configuration: the only thing Castle has put there is
444+
one directory at 0700, every configuration-bearing file it made is inside that,
445+
and the version directory itself holds nothing of Castle's but the two names it
446+
publishes. Against the version that created those files next to `sys.config`
447+
there is no such directory at all, so the assertion cannot pass by accident.
448+
The other discriminator, and the reason the mode ordering cannot be reversed by
449+
accident either, is the release whose `sys.config` is 0440: it materialises twice
450+
and both files end at 0440, where setting the mode first fails to write the file
451+
at all.
332452

333453
Two of these tests would pass for the wrong reason if written carelessly, so
334454
they are written to fail when what they rest on moves. The compile-environment

0 commit comments

Comments
 (0)