summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorReepca Russelstein <reepca@russelstein.xyz>2026-06-08 00:21:43 -0500
committerLudovic Courtès <ludo@gnu.org>2026-07-02 19:42:47 +0200
commited0a9721f8a20d6ddcf6a0495302f502b3f7bb17 (patch)
treeba06909769b65ca8dbf8b40fc87cdf6fe12ff5ce
parent980d0d6f76ce6c62ce127245e5396044672cf172 (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.scm45
-rw-r--r--guix/store/deduplication.scm25
-rw-r--r--tests/nar.scm79
-rw-r--r--tests/publish.scm32
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
555PROC on each file or directory read from PORT using: 562PROC 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
673extent 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
683EEXIST if FILE already exists.
647 684
648This procedure is suitable for use as the #:dump-file argument to 685This 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
315follow symlinks. Throw to key 'system-error with errno EEXIST if FILE already
316exists."
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. 328errno of EEXIST if FILE already exists. TYPE is a symbol, either 'regular or
329'executable.
317 330
318This procedure is suitable as a #:dump-file argument to 'restore-file'. When 331This procedure is suitable as a #:dump-file argument to 'restore-file'. When
319used that way, it deduplicates files on the fly as they are restored, thereby 332used that way, it deduplicates files on the fly as they are restored, thereby
320removing the need for a deduplication pass that would re-read all the files 333removing the need for a deduplication pass that would re-read all the files
321down the road." 334down 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: