Checking an upload before you open it
Source:vignettes/checking-an-upload.Rmd
checking-an-upload.RmdA service that accepts files has to decide what to do with each one before any parser sees it. The name is the uploader’s claim; the bytes are the evidence. This article walks through the decisions zumime supports, in the order a service makes them: is the file a type we accept, does its name agree with its bytes, can we open it at all, and, for a stream, how soon do we know.
A folder of uploads
Four uploads: a report, a “photo” that is really a PDF, a PDF someone encrypted, and one that was cut off in transit.
uploads <- tempfile("uploads")
dir.create(uploads)
pdf <- function(extra = "", eof = TRUE) {
paste0("%PDF-1.7\n1 0 obj << /Type /Catalog >> endobj\n",
"trailer << /Root 1 0 R", extra, " >>\n", if (eof) "%%EOF\n")
}
writeLines(pdf(), file.path(uploads, "report.pdf"))
writeLines(pdf(), file.path(uploads, "photo.jpg"))
writeLines(pdf(" /Encrypt 2 0 R"), file.path(uploads, "locked.pdf"))
writeLines(pdf(eof = FALSE), file.path(uploads, "partial.pdf"))
paths <- list.files(uploads, full.names = TRUE)
basename(paths)
#> [1] "locked.pdf" "partial.pdf" "photo.jpg" "report.pdf"Is it a type we accept?
mime_check() answers the allowlist question with one
logical per file. It reads only the bytes: a name is never evidence,
since the question exists because names lie. Here the service accepts
PDFs and images.
ok <- mime_check(paths, c("application/pdf", "image"))
setNames(ok, basename(paths))
#> locked.pdf partial.pdf photo.jpg report.pdf
#> TRUE TRUE TRUE TRUEEvery file passes, because every file is a PDF. An allowlist entry
can be a type, an alias or a top-level type such as
"image", and a type’s descendants count, so
"application/zip" accepts Word and Excel documents.
min_confidence says which evidence counts. The default,
"probable", accepts structured text found by its grammar
(JSON, SVG, a CSV through the family’s sniffers) as well as signatures;
"definite" accepts signatures and container contents
only.
json <- file.path(uploads, "data.json")
writeLines('{"rows": [1, 2, 3]}', json)
mime_check(json, "application/json")
#> [1] TRUE
mime_check(json, "application/json", min_confidence = "definite")
#> [1] FALSEmime_check() is always equal to asking
mime_type() and testing its answer, but it skips the work
that cannot change the verdict. When a file is refused,
mime_type() says why.
Does the name agree with the bytes?
mime_type() answers from both signals.
agree is FALSE where they contradict each
other, as for the PDF named .jpg:
files <- mime_type(paths)
files$path <- basename(files$path)
files[c("path", "type", "by_name", "agree")]
#> <zumime_result: 4 rows>
#> path type by_name agree
#> 1 locked.pdf application/pdf application/pdf TRUE
#> 2 partial.pdf application/pdf application/pdf TRUE
#> 3 photo.jpg application/pdf image/jpeg FALSE
#> 4 report.pdf application/pdf application/pdf TRUEA disagreement is a finding, not an error: a service might rename the file, store it under the type its bytes give, or refuse it.
Can we open it?
Two columns answer whether a parser should be started at all.
complete = TRUE reads the end of each file as well as its
start: a PDF needs %%EOF in its last kilobyte, a ZIP its
end-of-central-directory record, a gzip stream its trailer. With the end
read, a PDF’s trailer also says whether it is encrypted.
files <- mime_type(paths, complete = TRUE)
files$path <- basename(files$path)
files[c("path", "type", "encrypted", "complete")]
#> <zumime_result: 4 rows>
#> path type encrypted complete
#> 1 locked.pdf application/pdf TRUE TRUE
#> 2 partial.pdf application/pdf FALSE FALSE
#> 3 photo.jpg application/pdf FALSE TRUE
#> 4 report.pdf application/pdf FALSE TRUEencrypted is also read without complete for
formats that say so near the start: ZIP entries with the encryption flag
or an AES extra field, and OLE2 files (legacy Office, and encrypted
modern Office) with an EncryptionInfo stream.
A compressed file has another question: what is inside?
inner answers it from the rest of the name by default, or,
with inner = "decompress" and the zukomp package installed,
from the decompressed bytes.
How soon do we know, on a stream?
A service reading an upload from a socket can decide before the whole
file has arrived. mime_sniffer() takes chunks as they come
and answers as soon as the bytes decide the type. Here the “socket” is a
file connection read 16 bytes at a time:
s <- mime_sniffer()
con <- file(file.path(uploads, "report.pdf"), "rb")
seen <- 0
repeat {
chunk <- readBin(con, "raw", 16)
seen <- seen + length(chunk)
result <- mime_push(s, chunk)
if (!is.null(result) || length(chunk) == 0) break
}
close(con)
seen
#> [1] 16
result$type
#> [1] "application/pdf"A PDF is decided in its first chunk, because no other signature could
still match those bytes. Text is decided only at the sniffer’s
limit or at mime_finish(), since a later byte
can still change it; the answer is always the one
mime_sniff() gives for the same first limit
bytes.