diff options
| author | Reepca Russelstein <reepca@russelstein.xyz> | 2026-06-09 22:35:20 -0500 |
|---|---|---|
| committer | Ludovic Courtès <ludo@gnu.org> | 2026-07-02 19:42:47 +0200 |
| commit | fc06ada0602983350646c741531ec2b1887f3407 (patch) | |
| tree | 6189efa43a32ae9968931984d187b0b6b0364a8d | |
| parent | ed0a9721f8a20d6ddcf6a0495302f502b3f7bb17 (diff) | |
scripts: substitute: restrict where "file://" URIs can be used [security fix].
There are two potential places where file:// URIs could be used in 'guix
substitute':
1. The substitute urls, which are used for fetching narinfos. These can come
from the daemon, via daemon option "substitute-urls", from the client, via
daemon option "untrusted-substitute-urls", or from 'guix discover'. The
latter two must not be allowed to provide file:// URIs, since that could be
used to cause the daemon to read private files, files in /proc or /dev,
etc, and various parts of those files may show up in the backtrace from
'guix substitute'.
2. The narinfos themselves, which may offer arbitrarily many URIs to download
the nar of the store item in question. These URIs do not need to be
in the signed portion of the narinfo, so they may be attacker-controlled.
These should also not be allowed to provide file:// URIs, with the sole
exception being in the case of the test suite for the sake of simplifying
the tests.
To accomplish this, we error out of 'guix substitute' if
"untrusted-substitute-urls" includes a file:// URI, and we filter out all
file:// URIs from those provided by 'guix discover'. We also ignore all
narinfos containing file:// URIs unless '%allow-unsafe-substitute-uris?' is #t.
* guix/scripts/substitute.scm (%allow-unsafe-substitute-uris?,
%allow-unsafe-narinfo-uris?): new parameters.
(process-substitution, process-substitution/fallback): honor
'%allow-unsafe-substitute-uris?'.
(uri-safe?, narinfo-uris-safe?, assert-safe-uris): new procedures.
(%default-substitute-urls): use assert-safe-uris for the
"untrusted-substitute-urls" daemon option.
(%local-substitute-urls): filter out file:// URIs.
* tests/substitute.scm ("file:// URI prohibited by default"): new test. Set
'%allow-unsafe-substitute-uris?' to #t after it finishes.
Change-Id: I34d7e58b5a01c7cc61b52fcd61b547bba2ce4300
Signed-off-by: Ludovic Courtès <ludo@gnu.org>
| -rwxr-xr-x | guix/scripts/substitute.scm | 115 | ||||
| -rw-r--r-- | tests/substitute.scm | 20 |
2 files changed, 100 insertions, 35 deletions
diff --git a/guix/scripts/substitute.scm b/guix/scripts/substitute.scm index 455f5a23461..49f5389fe27 100755 --- a/guix/scripts/substitute.scm +++ b/guix/scripts/substitute.scm | |||
| @@ -58,6 +58,7 @@ | |||
| 58 | #:use-module (web response) | 58 | #:use-module (web response) |
| 59 | #:use-module (guix http-client) | 59 | #:use-module (guix http-client) |
| 60 | #:export (%allow-unauthenticated-substitutes? | 60 | #:export (%allow-unauthenticated-substitutes? |
| 61 | %allow-unsafe-substitute-uris? | ||
| 61 | %reply-file-descriptor | 62 | %reply-file-descriptor |
| 62 | 63 | ||
| 63 | substitute-urls | 64 | substitute-urls |
| @@ -90,6 +91,19 @@ disabled!~%")) | |||
| 90 | (and=> (getenv "GUIX_ALLOW_UNAUTHENTICATED_SUBSTITUTES") | 91 | (and=> (getenv "GUIX_ALLOW_UNAUTHENTICATED_SUBSTITUTES") |
| 91 | (cut string-ci=? <> "yes")))) | 92 | (cut string-ci=? <> "yes")))) |
| 92 | 93 | ||
| 94 | (define %allow-unsafe-substitute-uris? | ||
| 95 | ;; Whether to allow substitutes with a "file://" URI. These are useful for | ||
| 96 | ;; testing purposes but should never be allowed otherwise, as the URIs | ||
| 97 | ;; contained in narinfos are not signed and could point to any | ||
| 98 | ;; attacker-controlled location. Use GUIX_ALLOW_UNAUTHENTICATED_SUBSTITUTES | ||
| 99 | ;; as a way to tell when we're testing. | ||
| 100 | (make-parameter (%allow-unauthenticated-substitutes?))) | ||
| 101 | |||
| 102 | (define %allow-unsafe-narinfo-uris? | ||
| 103 | ;; Like %allow-unsafe-substitute-uris?, but affecting the urls used to fetch | ||
| 104 | ;; narinfos. | ||
| 105 | (make-parameter (%allow-unauthenticated-substitutes?))) | ||
| 106 | |||
| 93 | (define (at-most max-length lst) | 107 | (define (at-most max-length lst) |
| 94 | "If LST is shorter than MAX-LENGTH, return it and the empty list; otherwise | 108 | "If LST is shorter than MAX-LENGTH, return it and the empty list; otherwise |
| 95 | return its MAX-LENGTH first elements and its tail." | 109 | return its MAX-LENGTH first elements and its tail." |
| @@ -368,6 +382,21 @@ server certificates." | |||
| 368 | (drain-input socket) | 382 | (drain-input socket) |
| 369 | socket)))))))) | 383 | socket)))))))) |
| 370 | 384 | ||
| 385 | (define (uri-safe? uri) | ||
| 386 | "Return a boolean indicating whether URI, which is either a uri or its | ||
| 387 | string representation, is generally safe to use without requiring any trust." | ||
| 388 | (let ((uri (if (uri? uri) | ||
| 389 | uri | ||
| 390 | (string->uri uri)))) | ||
| 391 | (and uri | ||
| 392 | (case (uri-scheme uri) | ||
| 393 | ((#f file) #f) | ||
| 394 | (else #t))))) | ||
| 395 | |||
| 396 | (define (narinfo-uris-safe? narinfo) | ||
| 397 | (or (%allow-unsafe-substitute-uris?) | ||
| 398 | (every uri-safe? (narinfo-uris narinfo)))) | ||
| 399 | |||
| 371 | (define* (process-substitution/fallback narinfo destination | 400 | (define* (process-substitution/fallback narinfo destination |
| 372 | #:key cache-urls acl | 401 | #:key cache-urls acl |
| 373 | deduplicate? print-build-trace? | 402 | deduplicate? print-build-trace? |
| @@ -391,9 +420,10 @@ way to download the nar." | |||
| 391 | #:open-connection | 420 | #:open-connection |
| 392 | open-connection-for-uri/cached) | 421 | open-connection-for-uri/cached) |
| 393 | ((alternate) | 422 | ((alternate) |
| 394 | (if (or (equivalent-narinfo? narinfo alternate) | 423 | (if (and (narinfo-uris-safe? alternate) |
| 395 | (valid-narinfo? alternate acl) | 424 | (or (equivalent-narinfo? narinfo alternate) |
| 396 | (%allow-unauthenticated-substitutes?)) | 425 | (valid-narinfo? alternate acl) |
| 426 | (%allow-unauthenticated-substitutes?))) | ||
| 397 | (guard (c ((or (http-get-error? c) | 427 | (guard (c ((or (http-get-error? c) |
| 398 | (network-error? c)) | 428 | (network-error? c)) |
| 399 | (when (http-get-error? c) | 429 | (when (http-get-error? c) |
| @@ -429,6 +459,16 @@ PORT." | |||
| 429 | (const #t) | 459 | (const #t) |
| 430 | (cut valid-narinfo? <> acl)))) | 460 | (cut valid-narinfo? <> acl)))) |
| 431 | 461 | ||
| 462 | (define (fallback) | ||
| 463 | (process-substitution/fallback narinfo destination | ||
| 464 | #:cache-urls cache-urls | ||
| 465 | #:acl acl | ||
| 466 | #:deduplicate? deduplicate? | ||
| 467 | #:print-build-trace? | ||
| 468 | print-build-trace? | ||
| 469 | #:fast-decompression? | ||
| 470 | fast-decompression?)) | ||
| 471 | |||
| 432 | (unless narinfo | 472 | (unless narinfo |
| 433 | (raise | 473 | (raise |
| 434 | (formatted-message | 474 | (formatted-message |
| @@ -437,33 +477,30 @@ PORT." | |||
| 437 | 477 | ||
| 438 | (let ((expected-hash | 478 | (let ((expected-hash |
| 439 | actual-hash | 479 | actual-hash |
| 440 | (guard | 480 | (cond |
| 441 | (c ((or (http-get-error? c) | 481 | ((narinfo-uris-safe? narinfo) |
| 442 | (network-error? c)) | 482 | (guard |
| 443 | (when (http-get-error? c) | 483 | (c ((or (http-get-error? c) |
| 444 | (warning (G_ "download from '~a' failed: ~a, ~s~%") | 484 | (network-error? c)) |
| 445 | (uri->string (http-get-error-uri c)) | 485 | (when (http-get-error? c) |
| 446 | (http-get-error-code c) | 486 | (warning (G_ "download from '~a' failed: ~a, ~s~%") |
| 447 | (http-get-error-reason c))) | 487 | (uri->string (http-get-error-uri c)) |
| 448 | (format | 488 | (http-get-error-code c) |
| 449 | (current-error-port) | 489 | (http-get-error-reason c))) |
| 450 | (G_ "retrying download of '~a' with other substitute URLs...~%") | 490 | (format |
| 451 | store-item) | 491 | (current-error-port) |
| 452 | (process-substitution/fallback narinfo destination | 492 | (G_ "retrying download of '~a' with other substitute URLs...~%") |
| 453 | #:cache-urls cache-urls | 493 | store-item) |
| 454 | #:acl acl | 494 | (fallback))) |
| 455 | #:deduplicate? deduplicate? | 495 | (download-nar narinfo destination |
| 456 | #:print-build-trace? | 496 | #:deduplicate? deduplicate? |
| 457 | print-build-trace? | 497 | #:print-build-trace? print-build-trace? |
| 458 | #:fast-decompression? | 498 | #:fast-decompression? fast-decompression? |
| 459 | fast-decompression?))) | 499 | #:open-connection-for-uri |
| 460 | (download-nar narinfo destination | 500 | open-connection-for-uri/cached |
| 461 | #:deduplicate? deduplicate? | 501 | #:keep-alive? #t))) |
| 462 | #:print-build-trace? print-build-trace? | 502 | (else |
| 463 | #:fast-decompression? fast-decompression? | 503 | (fallback))))) |
| 464 | #:open-connection-for-uri | ||
| 465 | open-connection-for-uri/cached | ||
| 466 | #:keep-alive? #t)))) | ||
| 467 | (values narinfo | 504 | (values narinfo |
| 468 | expected-hash | 505 | expected-hash |
| 469 | actual-hash))) | 506 | actual-hash))) |
| @@ -519,10 +556,17 @@ substitutes may be unavailable\n"))))) | |||
| 519 | found." | 556 | found." |
| 520 | (assoc-ref (force options) option)))) | 557 | (assoc-ref (force options) option)))) |
| 521 | 558 | ||
| 559 | (define (assert-safe-uris uris) | ||
| 560 | (unless (or (%allow-unsafe-narinfo-uris?) | ||
| 561 | (every uri-safe? uris)) | ||
| 562 | (leave (G_ "unsafe or invalid URI in ~S~%") uris)) | ||
| 563 | uris) | ||
| 564 | |||
| 522 | (define %default-substitute-urls | 565 | (define %default-substitute-urls |
| 523 | (match (and=> (or (find-daemon-option "untrusted-substitute-urls") ;client | 566 | (match (or (and=> (find-daemon-option "untrusted-substitute-urls") ;client |
| 524 | (find-daemon-option "substitute-urls")) ;admin | 567 | (compose assert-safe-uris string-tokenize)) |
| 525 | string-tokenize) | 568 | (and=> (find-daemon-option "substitute-urls") ;admin |
| 569 | string-tokenize)) | ||
| 526 | ((urls ...) | 570 | ((urls ...) |
| 527 | urls) | 571 | urls) |
| 528 | (#f | 572 | (#f |
| @@ -558,8 +602,9 @@ is shorter than MAX elements, then it is directly returned." | |||
| 558 | (let* ((option (find-daemon-option "discover")) | 602 | (let* ((option (find-daemon-option "discover")) |
| 559 | (discover? (and option (string=? option "true")))) | 603 | (discover? (and option (string=? option "true")))) |
| 560 | (if discover? | 604 | (if discover? |
| 561 | (randomize-substitute-urls (read-substitute-urls)) | 605 | (randomize-substitute-urls (filter uri-safe? |
| 562 | '()))) | 606 | (read-substitute-urls))) |
| 607 | '()))) | ||
| 563 | 608 | ||
| 564 | (define substitute-urls | 609 | (define substitute-urls |
| 565 | ;; List of substitute URLs. | 610 | ;; List of substitute URLs. |
diff --git a/tests/substitute.scm b/tests/substitute.scm index cd75d56b981..10b74db084e 100644 --- a/tests/substitute.scm +++ b/tests/substitute.scm | |||
| @@ -206,6 +206,26 @@ a file for NARINFO." | |||
| 206 | ;; daemon. | 206 | ;; daemon. |
| 207 | (%reply-file-descriptor #f) | 207 | (%reply-file-descriptor #f) |
| 208 | 208 | ||
| 209 | (test-equal "file:// URI prohibited by default" | ||
| 210 | "not-found\n" | ||
| 211 | (with-output-to-string | ||
| 212 | (lambda () | ||
| 213 | (%allow-unsafe-substitute-uris? #f) | ||
| 214 | (let ((narinfo %narinfo)) | ||
| 215 | (with-narinfo (string-append narinfo "Signature: " | ||
| 216 | (signature-field narinfo) "\n") | ||
| 217 | (call-with-temporary-directory | ||
| 218 | (lambda (directory) | ||
| 219 | (with-input-from-string (string-append | ||
| 220 | "substitute " (%store-prefix) | ||
| 221 | "/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-foo " | ||
| 222 | directory "/shouldnotbewritten\n") | ||
| 223 | (lambda () | ||
| 224 | (guix-substitute "--substitute")))))))))) | ||
| 225 | |||
| 226 | ;; Allow these for ease of testing for the rest of the tests | ||
| 227 | (%allow-unsafe-substitute-uris? #t) | ||
| 228 | |||
| 209 | 229 | ||
| 210 | (test-equal "query narinfo without signature" | 230 | (test-equal "query narinfo without signature" |
| 211 | "" ; not substitutable | 231 | "" ; not substitutable |
