diff options
| author | Reepca Russelstein <reepca@russelstein.xyz> | 2026-06-08 00:21:43 -0500 |
|---|---|---|
| committer | Ludovic Courtès <ludo@gnu.org> | 2026-07-02 19:42:47 +0200 |
| commit | ed0a9721f8a20d6ddcf6a0495302f502b3f7bb17 (patch) | |
| tree | ba06909769b65ca8dbf8b40fc87cdf6fe12ff5ce /tests | |
| parent | 980d0d6f76ce6c62ce127245e5396044672cf172 (diff) | |
guix: serialization: validate directory entry names in fold-archive [security fix].
Also validate that they are in strictly ascending order, which also ensures
that there are no duplicate names.
When 'guix substitute' fetches a nar, it does so with a validly-signed hash
already known from the narinfo. But it can't verify that the hash of the nar
it's currently fetching matches until the full nar is downloaded. Until then,
'download-nar' will extract the nar into the specified destination using
'restore-path', which avoids having to keep a file of unbounded size in
memory. Critically, this means that the input that 'restore-path' (and by
extension 'fold-archive') is processing is untrusted, since substitute server
TLS certificates aren't verified (the narinfo signatures are supposed to make
it unnecessary).
As such, the scope of harm that can be caused by a malicious nar being
processed by 'restore-file' needs to be minimized.
* guix/serialization.scm (valid-nar-file-name?): new procedure.
(fold-archive): Use it to verify that directory entry names do not contain
'/' or '\0', and they are not equal to ".", "..", or "". Also verify that
they are in strictly ascending order.
(call-with-port*): new procedure.
(dump-file): use O_EXCL and O_NOFOLLOW. This precaution ensures that even
if 'restore-file' is somehow tricked into writing to a symlink (not
currently believed to be possible), it will result in an error.
* guix/store/deduplication.scm (call-with-fresh-output-file): new procedure.
(dump-file/deduplicate): use it for similar reasons as 'dump-file'.
* tests/nar.scm (call-with-tree-port, port-bad-nar?): new procedures.
("write-file-tree + fold-archive, unsorted directory entries",
"write-file-tree + fold-archive, duplicate directory entries",
"write-file-tree + fold-archive, invalid directory entries"): new tests.
* tests/publish.scm (call-with-temporary-output-filename): new procedure.
("/nar/*", "/nar/gzip/*", "/nar/lzip/*", "/nar/zstd/*", "/nar/ with properly
encoded '+' sign"): use it in these test cases so that the output filename
doesn't name an already-existing file.
Change-Id: I41f248c13d7af787233afad5cae102056329a68b
Signed-off-by: Ludovic Courtès <ludo@gnu.org>
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/nar.scm | 79 | ||||
| -rw-r--r-- | tests/publish.scm | 32 |
2 files changed, 101 insertions, 10 deletions
diff --git a/tests/nar.scm b/tests/nar.scm index 98752f20884..b446ea3d923 100644 --- a/tests/nar.scm +++ b/tests/nar.scm | |||
| @@ -35,6 +35,7 @@ | |||
| 35 | #:use-module (srfi srfi-34) | 35 | #:use-module (srfi srfi-34) |
| 36 | #:use-module (srfi srfi-35) | 36 | #:use-module (srfi srfi-35) |
| 37 | #:use-module (srfi srfi-64) | 37 | #:use-module (srfi srfi-64) |
| 38 | #:use-module (ice-9 binary-ports) | ||
| 38 | #:use-module (ice-9 ftw) | 39 | #:use-module (ice-9 ftw) |
| 39 | #:use-module (ice-9 regex) | 40 | #:use-module (ice-9 regex) |
| 40 | #:use-module ((ice-9 control) #:select (let/ec)) | 41 | #:use-module ((ice-9 control) #:select (let/ec)) |
| @@ -268,6 +269,84 @@ | |||
| 268 | (open-bytevector-input-port (get-bytevector)) | 269 | (open-bytevector-input-port (get-bytevector)) |
| 269 | "R")))) | 270 | "R")))) |
| 270 | 271 | ||
| 272 | (define (call-with-tree-port tree proc) | ||
| 273 | (let ((bv (call-with-output-bytevector | ||
| 274 | (lambda (port) | ||
| 275 | (apply write-file-tree "root" port tree))))) | ||
| 276 | (call-with-input-bytevector bv proc))) | ||
| 277 | |||
| 278 | (define (port-bad-nar? port) | ||
| 279 | (guard (c ((nar-error? c) | ||
| 280 | (pk 'nar-error c) | ||
| 281 | #t)) | ||
| 282 | (dynamic-wind | ||
| 283 | (const #t) | ||
| 284 | (lambda () | ||
| 285 | (rm-rf %test-dir) | ||
| 286 | (mkdir %test-dir) | ||
| 287 | (restore-file port (string-append %test-dir "/foo"))) | ||
| 288 | (lambda () | ||
| 289 | (false-if-exception (rm-rf %test-dir)))))) | ||
| 290 | |||
| 291 | (test-assert "write-file-tree + fold-archive, unsorted directory entries" | ||
| 292 | (let* ((unsorted-tree (list #:file-type+size | ||
| 293 | (match-lambda | ||
| 294 | ("root" (values 'directory 0)) | ||
| 295 | ("root/c" (values 'regular 1)) | ||
| 296 | ("root/b" (values 'regular 1)) | ||
| 297 | ("root/a" (values 'regular 1))) | ||
| 298 | #:file-port | ||
| 299 | (match-lambda | ||
| 300 | ("root/c" (open-input-string "c")) | ||
| 301 | ("root/b" (open-input-string "b")) | ||
| 302 | ("root/a" (open-input-string "a"))) | ||
| 303 | #:directory-entries | ||
| 304 | (match-lambda | ||
| 305 | ("root" '("c" "b" "a"))) | ||
| 306 | ;; We wish to deliberately create invalid | ||
| 307 | ;; entries | ||
| 308 | #:postprocess-entries identity))) | ||
| 309 | (call-with-tree-port unsorted-tree port-bad-nar?))) | ||
| 310 | |||
| 311 | (test-assert "write-file-tree + fold-archive, duplicate directory entries" | ||
| 312 | (let* ((duplicates-tree (list #:file-type+size | ||
| 313 | (match-lambda | ||
| 314 | ("root" (values 'directory 0)) | ||
| 315 | ("root/a" (values 'regular 1))) | ||
| 316 | #:file-port | ||
| 317 | (match-lambda | ||
| 318 | ("root/a" (open-input-string "a"))) | ||
| 319 | #:directory-entries | ||
| 320 | (match-lambda | ||
| 321 | ("root" '("a" "a" "a"))) | ||
| 322 | ;; We wish to deliberately create invalid | ||
| 323 | ;; entries | ||
| 324 | #:postprocess-entries identity))) | ||
| 325 | (call-with-tree-port duplicates-tree port-bad-nar?))) | ||
| 326 | |||
| 327 | (test-assert "write-file-tree + fold-archive, invalid directory entries" | ||
| 328 | (let* ((invalid-names (list "." ".." "" "../" "./" | ||
| 329 | "/" "/bin/shh" "../../etc/passwwwd" | ||
| 330 | (string #\nul) (string #\. #\/ #\nul))) | ||
| 331 | (invalid-name-trees (map (lambda (name) | ||
| 332 | (list #:file-type+size | ||
| 333 | (match-lambda | ||
| 334 | ("root" (values 'directory 0)) | ||
| 335 | (_ (values 'regular 1))) | ||
| 336 | #:file-port | ||
| 337 | (lambda (_) | ||
| 338 | (open-input-string "a")) | ||
| 339 | #:directory-entries | ||
| 340 | (match-lambda | ||
| 341 | ("root" (list name))) | ||
| 342 | ;; We wish to deliberately create | ||
| 343 | ;; invalid entries | ||
| 344 | #:postprocess-entries identity)) | ||
| 345 | invalid-names))) | ||
| 346 | (every (lambda (tree) | ||
| 347 | (call-with-tree-port tree port-bad-nar?)) | ||
| 348 | invalid-name-trees))) | ||
| 349 | |||
| 271 | (test-equal "write-file-tree + fold-archive, flat file" | 350 | (test-equal "write-file-tree + fold-archive, flat file" |
| 272 | '(("R" regular "abcdefg")) | 351 | '(("R" regular "abcdefg")) |
| 273 | 352 | ||
diff --git a/tests/publish.scm b/tests/publish.scm index 3c80c50d515..91576abf139 100644 --- a/tests/publish.scm +++ b/tests/publish.scm | |||
| @@ -96,6 +96,14 @@ | |||
| 96 | (lambda () | 96 | (lambda () |
| 97 | exp ...))))) | 97 | exp ...))))) |
| 98 | 98 | ||
| 99 | ;; 'call-with-temporary-output-file' produces a filename for a file that | ||
| 100 | ;; already exists, whereas this produces a filename for which no file exists | ||
| 101 | ;; inside a temporary directory. | ||
| 102 | (define (call-with-temporary-output-filename proc) | ||
| 103 | (call-with-temporary-directory | ||
| 104 | (lambda (directory) | ||
| 105 | (proc (string-append directory "/output"))))) | ||
| 106 | |||
| 99 | ;; guix-publish uses (current-processor-count) as the default number of | 107 | ;; guix-publish uses (current-processor-count) as the default number of |
| 100 | ;; workers, however on a system with a large number of cores, that large | 108 | ;; workers, however on a system with a large number of cores, that large |
| 101 | ;; number of worker threads being used in the course of these tests can end up | 109 | ;; number of worker threads being used in the course of these tests can end up |
| @@ -238,8 +246,8 @@ FileSize: ~a~%" | |||
| 238 | 246 | ||
| 239 | (test-equal "/nar/*" | 247 | (test-equal "/nar/*" |
| 240 | "bar" | 248 | "bar" |
| 241 | (call-with-temporary-output-file | 249 | (call-with-temporary-output-filename |
| 242 | (lambda (temp port) | 250 | (lambda (temp) |
| 243 | (let ((nar (utf8->string | 251 | (let ((nar (utf8->string |
| 244 | (http-get-body | 252 | (http-get-body |
| 245 | (publish-uri | 253 | (publish-uri |
| @@ -249,8 +257,8 @@ FileSize: ~a~%" | |||
| 249 | 257 | ||
| 250 | (test-equal "/nar/gzip/*" | 258 | (test-equal "/nar/gzip/*" |
| 251 | "bar" | 259 | "bar" |
| 252 | (call-with-temporary-output-file | 260 | (call-with-temporary-output-filename |
| 253 | (lambda (temp port) | 261 | (lambda (temp) |
| 254 | (let ((nar (http-get-port | 262 | (let ((nar (http-get-port |
| 255 | (publish-uri | 263 | (publish-uri |
| 256 | (string-append "/nar/gzip/" (basename %item)))))) | 264 | (string-append "/nar/gzip/" (basename %item)))))) |
| @@ -270,8 +278,8 @@ FileSize: ~a~%" | |||
| 270 | 278 | ||
| 271 | (test-equal "/nar/lzip/*" | 279 | (test-equal "/nar/lzip/*" |
| 272 | "bar" | 280 | "bar" |
| 273 | (call-with-temporary-output-file | 281 | (call-with-temporary-output-filename |
| 274 | (lambda (temp port) | 282 | (lambda (temp) |
| 275 | (let ((nar (http-get-port | 283 | (let ((nar (http-get-port |
| 276 | (publish-uri | 284 | (publish-uri |
| 277 | (string-append "/nar/lzip/" (basename %item)))))) | 285 | (string-append "/nar/lzip/" (basename %item)))))) |
| @@ -282,8 +290,8 @@ FileSize: ~a~%" | |||
| 282 | (unless (zstd-supported?) (test-skip 1)) | 290 | (unless (zstd-supported?) (test-skip 1)) |
| 283 | (test-equal "/nar/zstd/*" | 291 | (test-equal "/nar/zstd/*" |
| 284 | "bar" | 292 | "bar" |
| 285 | (call-with-temporary-output-file | 293 | (call-with-temporary-output-filename |
| 286 | (lambda (temp port) | 294 | (lambda (temp) |
| 287 | (let ((nar (http-get-port | 295 | (let ((nar (http-get-port |
| 288 | (publish-uri | 296 | (publish-uri |
| 289 | (string-append "/nar/zstd/" (basename %item)))))) | 297 | (string-append "/nar/zstd/" (basename %item)))))) |
| @@ -408,8 +416,8 @@ FileSize: ~a~%" | |||
| 408 | (test-equal "/nar/ with properly encoded '+' sign" | 416 | (test-equal "/nar/ with properly encoded '+' sign" |
| 409 | "Congrats!" | 417 | "Congrats!" |
| 410 | (let ((item (add-text-to-store %store "fake-gtk+" "Congrats!"))) | 418 | (let ((item (add-text-to-store %store "fake-gtk+" "Congrats!"))) |
| 411 | (call-with-temporary-output-file | 419 | (call-with-temporary-output-filename |
| 412 | (lambda (temp port) | 420 | (lambda (temp) |
| 413 | (let ((nar (utf8->string | 421 | (let ((nar (utf8->string |
| 414 | (http-get-body | 422 | (http-get-body |
| 415 | (publish-uri | 423 | (publish-uri |
| @@ -798,3 +806,7 @@ FileSize: ~a~%" | |||
| 798 | (http-post (publish-uri path)))))) | 806 | (http-post (publish-uri path)))))) |
| 799 | 807 | ||
| 800 | (test-end "publish") | 808 | (test-end "publish") |
| 809 | |||
| 810 | ;;; Local Variables: | ||
| 811 | ;;; eval: (put 'call-with-temporary-output-filename 'scheme-indent-function 0) | ||
| 812 | ;;; End: | ||
