aboutsummaryrefslogtreecommitdiff
path: root/bin
diff options
context:
space:
mode:
Diffstat (limited to 'bin')
-rw-r--r--bin/main.ml99
1 files changed, 90 insertions, 9 deletions
diff --git a/bin/main.ml b/bin/main.ml
index 4f1fbf2..6f3cbd5 100644
--- a/bin/main.ml
+++ b/bin/main.ml
@@ -561,6 +561,63 @@ let read_manifest out =
| Error _ -> []
| Ok contents -> String.split_on_char '\n' contents |> List.filter (fun l -> l <> "")
+(* Fix round 1 (coordinator review), CRITICAL: a manifest entry is
+ UNTRUSTED input the moment [--prune] reads it back. The manifest is a
+ plain-text file that lives INSIDE the very tree this feature exists to
+ have committed into a git repo -- an ordinary bad merge or a hand-edit is
+ enough to put an arbitrary path in it, no attacker required. Without a
+ check, an entry like "../outside/CANARY.txt" resolves, via
+ [Filename.concat out entry], to a path OUTSIDE [out], and the prune loop
+ below would [Sys.remove] it -- deleting a file [publish] never wrote,
+ breaking the "never deletes a file it does not own" contract outright.
+
+ Two independent checks, deliberately, because either alone is easy to
+ regress later without anyone noticing in review:
+
+ 1. STRUCTURAL ([manifest_entry_is_safe]) -- reject an entry that is
+ absolute, or that has a ".." path component anywhere. Split on '/'
+ and compare COMPONENTS, never a bare substring test: substring-
+ matching ".." would wrongly reject a legitimate name like
+ "foo..bar", which contains the two characters but has no ".."
+ component of its own.
+ 2. CONTAINMENT ([resolves_under]) -- even an entry that passes check 1
+ is not trusted until the path it actually resolves to, symlinks
+ included, is verified to sit under [out]. [Unix.realpath] resolves
+ symlinks as well as "..", so this also catches an entry that a
+ symlink planted inside [out] could use to defeat check 1 alone. A
+ plain string-prefix compare is not enough by itself either:
+ "/tmp/pub1" is a byte-prefix of "/tmp/pub1-evil", a directory that is
+ not nested inside it at all, so [is_under] insists the character
+ right after the prefix is the path separator (or that the paths are
+ identical). *)
+let manifest_entry_is_safe entry =
+ entry <> ""
+ && entry.[0] <> '/'
+ && not (List.mem ".." (String.split_on_char '/' entry))
+
+let is_under ~root path =
+ let root =
+ if String.length root > 1 && root.[String.length root - 1] = '/' then
+ String.sub root 0 (String.length root - 1)
+ else root
+ in
+ String.equal path root
+ || (String.length path > String.length root
+ && String.sub path 0 (String.length root) = root
+ && path.[String.length root] = '/')
+
+(* [Unix.realpath] requires the path to exist, which is fine here: every
+ caller below checks [Sys.file_exists]/[Sys.readdir] first. Any failure
+ (missing path, dangling symlink, permission error) is treated as "not
+ contained" -- refuse to act rather than guess. *)
+let resolves_under out p =
+ match Unix.realpath out with
+ | exception (Unix.Unix_error _ | Sys_error _) -> false
+ | out_real -> (
+ match Unix.realpath p with
+ | exception (Unix.Unix_error _ | Sys_error _) -> false
+ | p_real -> is_under ~root:out_real p_real)
+
(* [--prune] deletes the FILES a stale manifest entry names, but that alone
can leave their parent directories (ef/<year>/<mm>/, then ef/<year>/)
empty behind them -- and an empty directory still makes `test -d
@@ -568,9 +625,18 @@ let read_manifest out =
an old year is gone. Walk upward from each deleted file's own directory,
removing it while it is empty, stopping at (never including) [out]
itself: [out] is the caller's own directory, never ours to remove, even
- when it is empty. *)
+ when it is empty. The same containment discipline as the file deletions
+ above applies here too ([resolves_under]), not only structurally (this
+ function is only ever reached via a [p] the file-deletion path already
+ validated, but re-checking each directory step is the belt to that
+ entry's braces -- see the two-layer reasoning above). *)
let rec prune_empty_dirs ~out dir =
- if dir <> out && String.length dir > String.length out && Sys.file_exists dir then
+ if
+ dir <> out
+ && String.length dir > String.length out
+ && Sys.file_exists dir
+ && resolves_under out dir
+ then
match Sys.readdir dir with
| [||] ->
(try Unix.rmdir dir with Unix.Unix_error _ -> ());
@@ -679,16 +745,31 @@ let publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune =
emit "schema/day-v1.json" schema;
emit "index.html" (index_html ~from_y ~to_y);
let now = List.sort compare !written in
+ (* Every stale entry is validated TWICE before anything is removed --
+ see [manifest_entry_is_safe]/[resolves_under]'s own comment above for
+ why both layers exist. A rejected entry is skipped and warned about on
+ stderr, never fatal: a corrupt or hand-mangled manifest must not make
+ `publish` itself unusable -- it completes, having refused to act on
+ the bad line. *)
if prune then
List.iter
(fun old ->
- if not (List.mem old now) then begin
- let p = Filename.concat out old in
- if Sys.file_exists p then begin
- Sys.remove p;
- prune_empty_dirs ~out (Filename.dirname p)
- end
- end)
+ if not (List.mem old now) then
+ if not (manifest_entry_is_safe old) then
+ Printf.eprintf
+ "colitur: refusing to prune manifest entry %S (absolute path or .. component)
+" old
+ else begin
+ let p = Filename.concat out old in
+ if Sys.file_exists p then
+ if resolves_under out p then begin
+ Sys.remove p;
+ prune_empty_dirs ~out (Filename.dirname p)
+ end
+ else
+ Printf.eprintf "colitur: refusing to prune %s (resolves outside %s)
+" p out
+ end)
(read_manifest out);
write_file (Filename.concat out manifest_name) (String.concat "\n" now ^ "\n");
Printf.printf "colitur: wrote %d files to %s\n" (List.length now) out