diff options
| author | Reepca Russelstein <reepca@russelstein.xyz> | 2026-06-15 19:22:37 -0500 |
|---|---|---|
| committer | Ludovic Courtès <ludo@gnu.org> | 2026-07-02 19:42:47 +0200 |
| commit | 3e5c3217f334805531e5db1680d97490999253f7 (patch) | |
| tree | 6689196ba72866ff210cb97b1a5c7f4e0dbc497a /nix | |
| parent | fc06ada0602983350646c741531ec2b1887f3407 (diff) | |
daemon: libutil: make nar parser much stricter.
The prior implementation of 'parse' was, to put it mildly, very accepting of
invalid input. This made it very difficult to review its security properties,
which ended up relying entirely on the fact that it still didn't allow "/" in
directory entry names and that O_CREAT|O_EXCL implicitly causes symlinks to
not be followed in the final component, and mkdir and symlink never follow
symlinks in the final component, and all of the above throw exceptions when
they fail.
Some examples of what was allowed prior:
1. Empty archives:
"nix-archive-1" "(" ")"
2. Archives with only a type:
"nix-archive-1" "(" "type" "regular" ")"
3. Archives of regular files with multiple contents, which would be
concatenated (e.g. this has contents "foobar"):
"nix-archive-1" "(" "type" "regular" "contents" "foo" "contents" "bar" ")"
4. Archives of directories with multiple entries with the same name, and that
aren't sorted:
"nix-archive-1" "(" "type" "directory"
"entry" "(" "name" "c" "node" "(" ")" ")"
"entry" "(" "name" "b" "node" "(" ")" ")"
"entry" "(" "name" "b" "node" "(" ")" ")"
"entry" "(" "name" "a" "node" "(" ")" ")" ")"
5. Archives of directories with empty entries:
"nix-archive-1" "(" "type" "directory" "entry" "(" ")" ")"
6. Archives of directories with entries with no name:
"nix-archive-1" "(" "type" "directory" "entry" "(" "node" "(" ")" ")" ")"
7. Archives of directories with entries with no node:
"nix-archive-1" "(" "type" "directory" "entry" "(" "name" "a" ")" ")"
7. Archives of directories with entries with multiple names:
"nix-archive-1" "(" "type" "directory" "entry" "(" "name" "a"
"node" "(" ")"
"name" "b" ")" ")"
8. Archives of directories with entries with multiple nodes:
"nix-archive-1" "(" "type" "directory" "entry" "(" "node" "(" ")"
"node" "(" ")" ")" ")"
9. Archives of directories with entries with multiple names and multiple
nodes:
"nix-archive-1" "(" "type" "directory"
"entry" "(" "name" "a" "node" "(" ")"
"name" "b" "node" "(" ")" ")" ")"
Such permissiveness rather defeats the "normalized" part of "Normalized
ARchive".
Additionally, 'parse' previously used recursion with no explicit depth limit.
In practice Linux would still limit the depth to around 2048, since filenames
longer than 4096 bytes yield ENAMETOOLONG, but no such restriction exists on
Hurd.
The permissiveness issue is resolved by rewriting 'parse', and the unbounded
recursion issue is resolved by adding a nestLimit parameter so that no more
than DIRECTORY_NESTING_LIMIT (currently 256) levels of recursion are used. An
alternate implementation that doesn't use recursion, 'parse_unbounded', is
included in case it is determined that the nesting limit should be removed,
but currently only 'parse' is used.
* nix/libutil/archive.cc (parse): rewrite to add nestLimit parameter.
(parseDump): pass DIRECTORY_NESTING_LIMIT as nestLimit argument to 'parse'.
(parse_unbounded): new function.
Change-Id: Ia3c32cc645b609f6ae7f3bcafc491ec0073cbb51
Signed-off-by: Ludovic Courtès <ludo@gnu.org>
Diffstat (limited to 'nix')
| -rw-r--r-- | nix/libutil/archive.cc | 192 |
1 files changed, 136 insertions, 56 deletions
diff --git a/nix/libutil/archive.cc b/nix/libutil/archive.cc index 938fe8d4492..81c03f44d37 100644 --- a/nix/libutil/archive.cc +++ b/nix/libutil/archive.cc | |||
| @@ -155,94 +155,169 @@ struct CaseInsensitiveCompare | |||
| 155 | } | 155 | } |
| 156 | }; | 156 | }; |
| 157 | 157 | ||
| 158 | /* Used to place a consistent upper bound on recursion depth */ | ||
| 159 | #define DIRECTORY_NESTING_LIMIT 256 | ||
| 158 | 160 | ||
| 159 | static void parse(ParseSink & sink, Source & source, const Path & path) | 161 | static void parse(ParseSink & sink, Source & source, const Path & path, |
| 162 | int nestLimit) | ||
| 160 | { | 163 | { |
| 164 | if (nestLimit <= 0) throw Error("nar directory nesting limit reached"); | ||
| 161 | string s; | 165 | string s; |
| 162 | 166 | ||
| 163 | s = readString(source); | 167 | s = readString(source); |
| 164 | if (s != "(") throw badArchive("expected open tag"); | 168 | if (s != "(") throw badArchive("expected open tag"); |
| 165 | 169 | ||
| 166 | enum { tpUnknown, tpRegular, tpDirectory, tpSymlink } type = tpUnknown; | 170 | s = readString(source); |
| 167 | 171 | if (s != "type") throw badArchive("expected type tag"); | |
| 168 | std::map<Path, int, CaseInsensitiveCompare> names; | ||
| 169 | 172 | ||
| 170 | while (1) { | 173 | s = readString(source); |
| 171 | checkInterrupt(); | ||
| 172 | 174 | ||
| 175 | if (s == "regular") { | ||
| 176 | bool executable = false; | ||
| 173 | s = readString(source); | 177 | s = readString(source); |
| 174 | 178 | if (s == "executable") { | |
| 175 | if (s == ")") { | 179 | executable = true; |
| 176 | break; | 180 | readString(source); |
| 181 | s = readString(source); | ||
| 177 | } | 182 | } |
| 178 | 183 | ||
| 179 | else if (s == "type") { | 184 | if (s != "contents") throw badArchive("expected contents tag"); |
| 180 | if (type != tpUnknown) | ||
| 181 | throw badArchive("multiple type fields"); | ||
| 182 | string t = readString(source); | ||
| 183 | 185 | ||
| 184 | if (t == "regular") { | 186 | sink.createRegularFile(path); |
| 185 | type = tpRegular; | 187 | if (executable) sink.isExecutable(); |
| 186 | sink.createRegularFile(path); | 188 | parseContents(sink, source, path); |
| 187 | } | 189 | s = readString(source); |
| 190 | if (s != ")") throw badArchive("expected close tag"); | ||
| 191 | } | ||
| 192 | else if (s == "symlink") { | ||
| 193 | s = readString(source); | ||
| 194 | if (s != "target") throw badArchive("expected target tag"); | ||
| 195 | s = readString(source); | ||
| 196 | sink.createSymlink(path, s); | ||
| 197 | s = readString(source); | ||
| 198 | if (s != ")") throw badArchive("expected close tag"); | ||
| 199 | } | ||
| 200 | else if (s == "directory") { | ||
| 201 | string prevName; | ||
| 202 | sink.createDirectory(path); | ||
| 203 | while (true) { | ||
| 204 | checkInterrupt(); | ||
| 205 | s = readString(source); | ||
| 206 | if (s == ")") break; | ||
| 207 | if (s != "entry") throw badArchive("expected entry tag"); | ||
| 188 | 208 | ||
| 189 | else if (t == "directory") { | 209 | s = readString(source); |
| 190 | sink.createDirectory(path); | 210 | if (s != "(") throw badArchive("expected entry open tag"); |
| 191 | type = tpDirectory; | ||
| 192 | } | ||
| 193 | 211 | ||
| 194 | else if (t == "symlink") { | 212 | s = readString(source); |
| 195 | type = tpSymlink; | 213 | if (s != "name") throw badArchive("expected name tag"); |
| 196 | } | ||
| 197 | 214 | ||
| 198 | else throw badArchive("unknown file type " + t); | 215 | s = readString(source); |
| 216 | string name = s; | ||
| 217 | if (name.empty() || name == "." || name == ".." | ||
| 218 | || name.find('/') != string::npos | ||
| 219 | || name.find((char) 0) != string::npos) | ||
| 220 | throw Error(std::format("NAR contains invalid file name `{}'", name)); | ||
| 221 | if (!prevName.empty() && name <= prevName) | ||
| 222 | throw Error("NAR directory is not sorted"); | ||
| 199 | 223 | ||
| 200 | } | 224 | s = readString(source); |
| 225 | if (s != "node") throw badArchive("expected node tag"); | ||
| 226 | |||
| 227 | parse(sink, source, path + "/" + name, nestLimit - 1); | ||
| 201 | 228 | ||
| 202 | else if (s == "contents" && type == tpRegular) { | 229 | s = readString(source); |
| 203 | parseContents(sink, source, path); | 230 | if (s != ")") throw badArchive("expected entry close tag"); |
| 231 | prevName = name; | ||
| 204 | } | 232 | } |
| 233 | } | ||
| 234 | else throw badArchive("unknown type"); | ||
| 235 | } | ||
| 205 | 236 | ||
| 206 | else if (s == "executable" && type == tpRegular) { | 237 | /* Unbounded variant that doesn't recurse, uses path as its stack */ |
| 238 | static void parse_unbounded(ParseSink & sink, Source & source, Path & path) | ||
| 239 | { | ||
| 240 | string prevName; | ||
| 241 | string s; | ||
| 242 | start: | ||
| 243 | s = readString(source); | ||
| 244 | if (s != "(") throw badArchive("expected open tag"); | ||
| 245 | |||
| 246 | s = readString(source); | ||
| 247 | if (s != "type") throw badArchive("expected type tag"); | ||
| 248 | |||
| 249 | s = readString(source); | ||
| 250 | |||
| 251 | if (s == "regular") { | ||
| 252 | bool executable = false; | ||
| 253 | s = readString(source); | ||
| 254 | if (s == "executable") { | ||
| 255 | executable = true; | ||
| 207 | readString(source); | 256 | readString(source); |
| 208 | sink.isExecutable(); | 257 | s = readString(source); |
| 209 | } | 258 | } |
| 210 | 259 | ||
| 211 | else if (s == "entry" && type == tpDirectory) { | 260 | if (s != "contents") throw badArchive("expected contents tag"); |
| 212 | string name, prevName; | 261 | |
| 262 | sink.createRegularFile(path); | ||
| 263 | if (executable) sink.isExecutable(); | ||
| 264 | parseContents(sink, source, path); | ||
| 265 | s = readString(source); | ||
| 266 | if (s != ")") throw badArchive("expected close tag"); | ||
| 267 | } | ||
| 268 | else if (s == "symlink") { | ||
| 269 | s = readString(source); | ||
| 270 | if (s != "target") throw badArchive("expected target tag"); | ||
| 271 | s = readString(source); | ||
| 272 | sink.createSymlink(path, s); | ||
| 273 | s = readString(source); | ||
| 274 | if (s != ")") throw badArchive("expected close tag"); | ||
| 275 | } | ||
| 276 | else if (s == "directory") { | ||
| 277 | sink.createDirectory(path); | ||
| 278 | while (true) { | ||
| 279 | checkInterrupt(); | ||
| 280 | s = readString(source); | ||
| 281 | if (s == ")") break; | ||
| 282 | if (s != "entry") throw badArchive("expected entry tag"); | ||
| 283 | |||
| 284 | s = readString(source); | ||
| 285 | if (s != "(") throw badArchive("expected entry open tag"); | ||
| 213 | 286 | ||
| 214 | s = readString(source); | 287 | s = readString(source); |
| 215 | if (s != "(") throw badArchive("expected open tag"); | 288 | if (s != "name") throw badArchive("expected name tag"); |
| 216 | 289 | ||
| 217 | while (1) { | 290 | s = readString(source); |
| 218 | checkInterrupt(); | 291 | { |
| 292 | string name = s; | ||
| 293 | if (name.empty() || name == "." || name == ".." | ||
| 294 | || name.find('/') != string::npos | ||
| 295 | || name.find((char) 0) != string::npos) | ||
| 296 | throw Error(std::format("NAR contains invalid file name `{}'", name)); | ||
| 297 | if (!prevName.empty() && name <= prevName) | ||
| 298 | throw Error("NAR directory is not sorted"); | ||
| 219 | 299 | ||
| 220 | s = readString(source); | 300 | s = readString(source); |
| 301 | if (s != "node") throw badArchive("expected node tag"); | ||
| 221 | 302 | ||
| 222 | if (s == ")") { | 303 | /* parse(sink, source, path + "/" + name); */ |
| 223 | break; | 304 | path.append("/" + name); |
| 224 | } else if (s == "name") { | 305 | prevName = ""; |
| 225 | name = readString(source); | 306 | goto start; |
| 226 | if (name.empty() || name == "." || name == ".." || name.find('/') != string::npos || name.find((char) 0) != string::npos) | ||
| 227 | throw Error(std::format("NAR contains invalid file name `{}'", name)); | ||
| 228 | if (name <= prevName) | ||
| 229 | throw Error("NAR directory is not sorted"); | ||
| 230 | prevName = name; | ||
| 231 | } else if (s == "node") { | ||
| 232 | if (s.empty()) throw badArchive("entry name missing"); | ||
| 233 | parse(sink, source, path + "/" + name); | ||
| 234 | } else | ||
| 235 | throw badArchive("unknown field " + s); | ||
| 236 | } | 307 | } |
| 237 | } | ||
| 238 | 308 | ||
| 239 | else if (s == "target" && type == tpSymlink) { | 309 | continue_directory: |
| 240 | string target = readString(source); | 310 | s = readString(source); |
| 241 | sink.createSymlink(path, target); | 311 | if (s != ")") throw badArchive("expected entry close tag"); |
| 242 | } | 312 | } |
| 313 | } | ||
| 314 | else throw badArchive("unknown type"); | ||
| 243 | 315 | ||
| 244 | else | 316 | string::size_type slash_pos = path.rfind("/"); |
| 245 | throw badArchive("unknown field " + s); | 317 | if (slash_pos != string::npos) { |
| 318 | prevName = string(path, slash_pos+1); | ||
| 319 | path.erase(slash_pos); | ||
| 320 | goto continue_directory; | ||
| 246 | } | 321 | } |
| 247 | } | 322 | } |
| 248 | 323 | ||
| @@ -258,7 +333,12 @@ void parseDump(ParseSink & sink, Source & source) | |||
| 258 | } | 333 | } |
| 259 | if (version != archiveVersion1) | 334 | if (version != archiveVersion1) |
| 260 | throw badArchive("input doesn't look like a normalized archive"); | 335 | throw badArchive("input doesn't look like a normalized archive"); |
| 261 | parse(sink, source, ""); | 336 | |
| 337 | parse(sink, source, "", DIRECTORY_NESTING_LIMIT); | ||
| 338 | /* | ||
| 339 | string file = ""; | ||
| 340 | parse_unbounded(sink, source, file); | ||
| 341 | */ | ||
| 262 | } | 342 | } |
| 263 | 343 | ||
| 264 | 344 | ||
