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 | |
| 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>
| -rw-r--r-- | guix/serialization.scm | 45 | ||||
| -rw-r--r-- | guix/store/deduplication.scm | 25 | ||||
| -rw-r--r-- | tests/nar.scm | 79 | ||||
| -rw-r--r-- | tests/publish.scm | 32 |
4 files changed, 164 insertions, 17 deletions
diff --git a/guix/serialization.scm b/guix/serialization.scm index c0f5ee5e86d..54ff5da03d9 100644 --- a/guix/serialization.scm +++ b/guix/serialization.scm | |||
| @@ -550,6 +550,13 @@ which case you can use 'identity'." | |||
| 550 | (define port-conversion-strategy | 550 | (define port-conversion-strategy |
| 551 | (fluid->parameter %default-port-conversion-strategy)) | 551 | (fluid->parameter %default-port-conversion-strategy)) |
| 552 | 552 | ||
| 553 | (define (valid-nar-file-name? name) | ||
| 554 | (not (or (string-null? name) | ||
| 555 | (string-index name #\/) | ||
| 556 | (string-index name #\nul) | ||
| 557 | (string=? name "..") | ||
| 558 | (string=? name ".")))) | ||
| 559 | |||
| 553 | (define (fold-archive proc seed port file) | 560 | (define (fold-archive proc seed port file) |
| 554 | "Read a file (possibly a directory structure) in Nar format from PORT. Call | 561 | "Read a file (possibly a directory structure) in Nar format from PORT. Call |
| 555 | PROC on each file or directory read from PORT using: | 562 | PROC on each file or directory read from PORT using: |
| @@ -610,6 +617,7 @@ depends on TYPE." | |||
| 610 | (("(" "type" "directory") | 617 | (("(" "type" "directory") |
| 611 | (let ((dir file)) | 618 | (let ((dir file)) |
| 612 | (let loop ((prefix (read-string port)) | 619 | (let loop ((prefix (read-string port)) |
| 620 | (previous #f) ; for verifying ordering and uniqueness | ||
| 613 | (result (proc file 'directory #f result))) | 621 | (result (proc file 'directory #f result))) |
| 614 | (match prefix | 622 | (match prefix |
| 615 | ("entry" | 623 | ("entry" |
| @@ -617,6 +625,23 @@ depends on TYPE." | |||
| 617 | (read-string port) (read-string port) | 625 | (read-string port) (read-string port) |
| 618 | (read-string port)) | 626 | (read-string port)) |
| 619 | (("(" "name" file "node") | 627 | (("(" "name" file "node") |
| 628 | (unless (valid-nar-file-name? file) | ||
| 629 | (raise | ||
| 630 | (condition | ||
| 631 | (&message (message "invalid filename")) | ||
| 632 | (&nar-read-error (port port) | ||
| 633 | (file dir) | ||
| 634 | (token file))))) | ||
| 635 | (when (and previous | ||
| 636 | (string>=? previous file)) | ||
| 637 | (raise | ||
| 638 | (condition | ||
| 639 | (&message | ||
| 640 | ;; This also catches duplicate entries. | ||
| 641 | (message "directory entry not in strictly ascending order")) | ||
| 642 | (&nar-read-error (port port) | ||
| 643 | (file dir) | ||
| 644 | (token file))))) | ||
| 620 | (let ((result (read (string-append dir "/" file) result))) | 645 | (let ((result (read (string-append dir "/" file) result))) |
| 621 | (match (read-string port) | 646 | (match (read-string port) |
| 622 | (")" #f) | 647 | (")" #f) |
| @@ -628,7 +653,7 @@ depends on TYPE." | |||
| 628 | (&nar-read-error (port port) | 653 | (&nar-read-error (port port) |
| 629 | (file file) | 654 | (file file) |
| 630 | (token x)))))) | 655 | (token x)))))) |
| 631 | (loop (read-string port) result))))) | 656 | (loop (read-string port) file result))))) |
| 632 | (")" ;done with DIR | 657 | (")" ;done with DIR |
| 633 | (proc file 'directory-complete #f result)) | 658 | (proc file 'directory-complete #f result)) |
| 634 | (x | 659 | (x |
| @@ -642,12 +667,25 @@ depends on TYPE." | |||
| 642 | (&message (message "unsupported nar entry type")) | 667 | (&message (message "unsupported nar entry type")) |
| 643 | (&nar-read-error (port port) (file file) (token x))))))))) | 668 | (&nar-read-error (port port) (file file) (token x))))))))) |
| 644 | 669 | ||
| 670 | |||
| 671 | (define (call-with-port* port proc) | ||
| 672 | "Like call-with-port, but closes PORT unconditionally when PROC's dynamic | ||
| 673 | extent is left instead of only when PROC finishes normally." | ||
| 674 | (dynamic-wind | ||
| 675 | (const #t) | ||
| 676 | (lambda () | ||
| 677 | (proc port)) | ||
| 678 | (lambda () | ||
| 679 | (close-port port)))) | ||
| 680 | |||
| 645 | (define (dump-file file input size type) | 681 | (define (dump-file file input size type) |
| 646 | "Dump SIZE bytes from INPUT to FILE. | 682 | "Dump SIZE bytes from INPUT to FILE, throwing system-error with an errno of |
| 683 | EEXIST if FILE already exists. | ||
| 647 | 684 | ||
| 648 | This procedure is suitable for use as the #:dump-file argument to | 685 | This procedure is suitable for use as the #:dump-file argument to |
| 649 | 'restore-file'." | 686 | 'restore-file'." |
| 650 | (call-with-output-file file | 687 | (call-with-port* (open file (logior O_WRONLY O_CREAT O_EXCL |
| 688 | O_NOFOLLOW O_CLOEXEC)) | ||
| 651 | (lambda (output) | 689 | (lambda (output) |
| 652 | (dump input output size)))) | 690 | (dump input output size)))) |
| 653 | 691 | ||
| @@ -685,6 +723,7 @@ a custom procedure, for instance to deduplicate FILE on the fly." | |||
| 685 | 723 | ||
| 686 | ;;; Local Variables: | 724 | ;;; Local Variables: |
| 687 | ;;; eval: (put 'call-with-binary-input-file 'scheme-indent-function 1) | 725 | ;;; eval: (put 'call-with-binary-input-file 'scheme-indent-function 1) |
| 726 | ;;; eval: (put 'call-with-port* 'scheme-indent-function 1) | ||
| 688 | ;;; End: | 727 | ;;; End: |
| 689 | 728 | ||
| 690 | ;;; serialization.scm ends here | 729 | ;;; serialization.scm ends here |
diff --git a/guix/store/deduplication.scm b/guix/store/deduplication.scm index 2005653c95c..a9deceb3765 100644 --- a/guix/store/deduplication.scm +++ b/guix/store/deduplication.scm | |||
| @@ -310,17 +310,30 @@ OUTPUT as it goes." | |||
| 310 | 310 | ||
| 311 | (make-custom-binary-input-port "tee input port" read! #f #f #f)) | 311 | (make-custom-binary-input-port "tee input port" read! #f #f #f)) |
| 312 | 312 | ||
| 313 | (define (call-with-fresh-output-file file proc) | ||
| 314 | "Like call-with-output-file, but ensure FILE is newly-created and does not | ||
| 315 | follow symlinks. Throw to key 'system-error with errno EEXIST if FILE already | ||
| 316 | exists." | ||
| 317 | (let ((port (open file (logior O_WRONLY O_CREAT O_EXCL | ||
| 318 | O_NOFOLLOW O_CLOEXEC)))) | ||
| 319 | (dynamic-wind (const #t) | ||
| 320 | (lambda () | ||
| 321 | (proc port)) | ||
| 322 | (lambda () | ||
| 323 | (close-port port))))) | ||
| 324 | |||
| 313 | (define* (dump-file/deduplicate file input size type | 325 | (define* (dump-file/deduplicate file input size type |
| 314 | #:key (store (%store-directory))) | 326 | #:key (store (%store-directory))) |
| 315 | "Write SIZE bytes read from INPUT to FILE. TYPE is a symbol, either | 327 | "Write SIZE bytes read from INPUT to FILE, throwing system-error with an |
| 316 | 'regular or 'executable. | 328 | errno of EEXIST if FILE already exists. TYPE is a symbol, either 'regular or |
| 329 | 'executable. | ||
| 317 | 330 | ||
| 318 | This procedure is suitable as a #:dump-file argument to 'restore-file'. When | 331 | This procedure is suitable as a #:dump-file argument to 'restore-file'. When |
| 319 | used that way, it deduplicates files on the fly as they are restored, thereby | 332 | used that way, it deduplicates files on the fly as they are restored, thereby |
| 320 | removing the need for a deduplication pass that would re-read all the files | 333 | removing the need for a deduplication pass that would re-read all the files |
| 321 | down the road." | 334 | down the road." |
| 322 | (define (dump-and-compute-hash) | 335 | (define (dump-and-compute-hash) |
| 323 | (call-with-output-file file | 336 | (call-with-fresh-output-file file |
| 324 | (lambda (output) | 337 | (lambda (output) |
| 325 | (let-values (((hash-port get-hash) | 338 | (let-values (((hash-port get-hash) |
| 326 | (open-hash-port (hash-algorithm sha256)))) | 339 | (open-hash-port (hash-algorithm sha256)))) |
| @@ -333,7 +346,7 @@ down the road." | |||
| 333 | 346 | ||
| 334 | (if (>= size %deduplication-minimum-size) | 347 | (if (>= size %deduplication-minimum-size) |
| 335 | (deduplicate file (dump-and-compute-hash) #:store store) | 348 | (deduplicate file (dump-and-compute-hash) #:store store) |
| 336 | (call-with-output-file file | 349 | (call-with-fresh-output-file file |
| 337 | (lambda (output) | 350 | (lambda (output) |
| 338 | (if (file-port? input) | 351 | (if (file-port? input) |
| 339 | (sendfile output input size 0) | 352 | (sendfile output input size 0) |
| @@ -352,3 +365,7 @@ down the road." | |||
| 352 | 'regular | 365 | 'regular |
| 353 | 'executable) | 366 | 'executable) |
| 354 | #:store store))))) | 367 | #:store store))))) |
| 368 | |||
| 369 | ;;; Local Variables: | ||
| 370 | ;;; eval: (put 'call-with-fresh-output-file 'scheme-indent-function 1) | ||
| 371 | ;;; End: | ||
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: | ||
