DOKIMASecurity review report

Overview

Unbounded in-memory buffering during TIFF format detection in image preview

low Possibly Valid medium confidence

DOK-100123-FILEBROWSER-PREVIEW-THUMBNAILS-PREVIEW-DETECTFORM · filebrowser · preview-thumbnails

Status

Possibly Valid

Verified verbatim: detectFormat buffers all bytes read during format detection via io.TeeReader (service.go:196-197) and the 10000x10000 guard runs afterward and only bounds decoded pixel dimensions (service.go:205-208). Confirmed in pinned x/image@v0.39.0 that TIFF newDecoder reads an attacker-controlled 32-bit ifdOffset (reader.go:474) and ReadAt at that offset forces buffer.fill to materialize all bytes 0..ifdOffset in memory (buffer.go:21-47,60-68), and that the TeeReader (non-ReaderAt) defeats the seekable-file random-access fast path. A 1x1 IFD passes the guard, and imaging.Decode re-buffers. Reachable by an authenticated Perm.Download user via a .tiff preview with concurrency bounded to 4. The weakness (untrusted preview input fully materialized in memory with no io.LimitReader, independent of the only decode-size control) is completely proven; held at possibly_valid because the realized OOM impact depends on the attacker placing GB-scale files and on host memory limits - deployment facts. Genuinely distinct sink from the other preview findings, so lifecycle new; low severity because it needs GB-scale inputs for only ~2-4x amplification, consistent with sibling resource-abuse verdicts.

Repository / Component

Repository
filebrowser
Component
preview-thumbnails

Plain-English Description

When the server makes a thumbnail of a TIFF image, it can be tricked into loading a huge amount of the file into memory before any size limit applies. A user with a very large crafted TIFF can spike server memory and, with a few concurrent requests, risk crashing it.

Description of the Underlying Issue

detectFormat wraps the preview input in an io.TeeReader (img/service.go:196-197) so every byte read during format detection is copied into an in-memory bytes.Buffer. The 10000x10000 MaxImageWidth/MaxImageHeight guard runs only after image.DecodeConfig returns and only checks decoded Width/Height (service.go:205-208), so it cannot bound bytes buffered during detection. For TIFF, x/image reads an attacker-controlled 32-bit ifdOffset from header bytes 4-7 (x/image@v0.39.0 tiff/reader.go:474) and performs ReadAt at that offset (reader.go:477); because the TeeReader is not an io.ReaderAt, newReaderAt must buffer (buffer.go:60-68) and buffer.fill sequentially reads all bytes 0..ifdOffset into memory (buffer.go:21-47), defeating the seekable-fd fast path. A 1x1 IFD passes the dimension guard, and imaging.Decode re-decodes from io.MultiReader(buf, in), re-buffering a second time. This is a distinct sink from the pixel-flood decode-dimension finding (DOK-100025/100107) and the go-exif thumbnail-length finding (DOK-100097/100108) — it is raw input-byte buffering during TIFF format detection.

Potential Attack

An authenticated Perm.Download user crafts a little-endian TIFF ('II', 0x2A00, a 4-byte ifdOffset near 2^31), pads the file out to that offset, and places a minimal 1x1 IFD there. Named evil.tiff (or any image-gated extension carrying TIFF magic), it is placed in a previewable scope. Requesting GET /api/preview/{size}/{path} causes detectFormat's TeeReader plus the x/image TIFF buffer to read every byte up to ifdOffset into memory — twice (DecodeConfig then imaging.Decode) — before the 1x1 dims pass the guard. Up to imageProcessors (default 4) concurrent requests multiply the transient footprint.

Outcomes of Potential Attack

Transient memory amplification of roughly 2-4x the on-disk file size per request, times up to 4 concurrent decodes, with no io.LimitReader bound. With GB-scale crafted inputs and constrained host memory this can drive the shared process toward OOM / denial of service. The realized crash depends on deployment memory sizing.

Affected Scope

GET /api/preview/{size}/{path} -> previewHandler -> handleImagePreview -> createPreview -> img.Service.Resize -> detectFormat, for inputs that content-sniff as TIFF (file named .tiff/.tif, or any image-gated extension .png/.jpg/.bmp carrying TIFF magic bytes). Requires an authenticated Perm.Download user with ResizePreview or EnableThumbnails enabled (both default on). Concurrency bounded to imageProcessors (default 4).

Suggested Fix (plain english)

Cap how much of a preview file is read into memory before decoding, so oversized or malformed images are rejected instead of buffered whole.

Suggested Fix (detailed)

Wrap the preview input in an io.LimitReader with a sane cap (a few MB to low tens of MB) before detectFormat/DecodeConfig so both format detection and decode operate on a bounded prefix, and reject oversized inputs (413/422). This single input byte-size cap also subsumes the pixel-flood (DOK-100025/100107) and go-exif thumbnail-length (DOK-100097/100108) memory sinks. Optionally avoid the TeeReader whole-buffer pattern by seeking a seekable fd back to 0 after detection (preserving x/image's io.ReaderAt fast path) and thread r.Context() so client disconnect aborts buffering. Verify by previewing a crafted TIFF with a near-2^31 ifdOffset and confirming memory stays bounded and the request is rejected. Wrap the preview input in an io.LimitReader with a sane cap (a few MB to low tens of MB) before detectFormat/DecodeConfig so both format detection and decode operate on a bounded prefix, and reject oversized inputs (413/422). A single input byte-size cap also subsumes the pixel-flood (DOK-100025/100107) and go-exif thumbnail-length (DOK-100097/100108) memory sinks. Optionally avoid the TeeReader whole-buffer pattern by seeking a seekable fd back to 0 after detection (preserving x/image's io.ReaderAt fast path) and/or thread r.Context() so client disconnect aborts buffering.

Validation

Verified verbatim: detectFormat buffers all bytes read during format detection via io.TeeReader (service.go:196-197) and the 10000x10000 guard runs afterward and only bounds decoded pixel dimensions (service.go:205-208). Confirmed in pinned x/image@v0.39.0 that TIFF newDecoder reads an attacker-controlled 32-bit ifdOffset (reader.go:474) and ReadAt at that offset forces buffer.fill to materialize all bytes 0..ifdOffset in memory (buffer.go:21-47,60-68), and that the TeeReader (non-ReaderAt) defeats the seekable-file random-access fast path. A 1x1 IFD passes the guard, and imaging.Decode re-buffers. Reachable by an authenticated Perm.Download user via a .tiff preview with concurrency bounded to 4. The weakness (untrusted preview input fully materialized in memory with no io.LimitReader, independent of the only decode-size control) is completely proven; held at possibly_valid because the realized OOM impact depends on the attacker placing GB-scale files and on host memory limits - deployment facts. Genuinely distinct sink from the other preview findings, so lifecycle new; low severity because it needs GB-scale inputs for only ~2-4x amplification, consistent with sibling resource-abuse verdicts.

first_opinion

Full Evidence

img/service.go:196-197 - detectFormat: buf := &bytes.Buffer{}; r := io.TeeReader(in, buf) copies every byte read during format detection into an in-memory buffer
img/service.go:199 - image.DecodeConfig(r) reads from the TeeReader; TIFF magic dispatches to golang.org/x/image tiff.DecodeConfig
img/service.go:205-208 - the MaxImageWidth/MaxImageHeight (10000x10000) guard is evaluated only AFTER DecodeConfig returns and checks decoded imgConfig.Width/Height, so it cannot bound bytes already buffered during detection
img/service.go:215 - returns io.MultiReader(buf, in); the whole buffered prefix is handed to imaging.Decode (service.go:178), which re-decodes and re-buffers
golang.org/x/image@v0.39.0/tiff/reader.go:452-454 - newDecoder wraps its reader via newReaderAt
golang.org/x/image@v0.39.0/tiff/reader.go:474 - ifdOffset := int64(d.byteOrder.Uint32(p[4:8])) reads the IFD offset from header bytes 4-7 (attacker-controlled uint32)
golang.org/x/image@v0.39.0/tiff/reader.go:477 - d.r.ReadAt(p[0:2], ifdOffset) triggers a read at the attacker offset
golang.org/x/image@v0.39.0/tiff/buffer.go:60-68 - newReaderAt wraps a non-ReaderAt reader in buffer{}; the TeeReader is not an io.ReaderAt so buffering is forced even though the underlying *os.File is seekable
golang.org/x/image@v0.39.0/tiff/buffer.go:21-47 - buffer.fill(end) sequentially io.ReadFull's data in 10MB chunks until the buffer holds end bytes; ReadAt(_, ifdOffset) => fill(ifdOffset+2) materializes all bytes 0..ifdOffset in memory
http/preview.go:39 - previewHandler requires d.user.Perm.Download
files/file.go:232,251-252 - mime.TypeByExtension('.tiff') yields image/tiff so file.Type becomes image without reading content
http/preview.go:86-90 - FormatFromExtension gate passes for .tiff (FormatTiff) and only rejects unsupported/GIF extensions
http/preview.go:141 - createPreview passes the opened fd to Resize; detectFormat's TeeReader defeats fd's io.ReaderAt, forcing buffering
img/service.go:148 + cmd/root.go:93 - decode runs under sem.Acquire with imageProcessors default 4
Proven fact: detectFormat copies every byte read during format detection into an in-memory bytes.Buffer via io.TeeReader (service.go:196-197).
Proven fact: The 10000x10000 dimension guard runs only after image.DecodeConfig returns and checks only decoded Width/Height, not input bytes read during detection (service.go:199,205-208).
Proven fact: For TIFF, x/image reads the IFD located at an attacker-controlled 32-bit ifdOffset (reader.go:474) via ReadAt at that offset (reader.go:477), which forces buffer.fill to sequentially read and hold all bytes 0..ifdOffset in memory (buffer.go:21-47).
Proven fact: Because detectFormat wraps the input in a TeeReader (not an io.ReaderAt), x/image's newReaderAt cannot use random access and must buffer, even though the underlying file is seekable (buffer.go:60-68).
Proven fact: A TIFF whose IFD declares ImageWidth=ImageLength=1 yields decoded dimensions that pass the guard, so the guard provides zero protection against the input-buffering path.
Proven fact: imaging.Decode (service.go:178) re-decodes from io.MultiReader(buf, in) and the TIFF decoder re-buffers up to ifdOffset a second time.
Proven fact: The path is reachable by an authenticated Perm.Download user previewing a .tiff (or image-extension) file with previews enabled (default); concurrency is bounded to imageProcessors (default 4).
Proven fact: This is a distinct sink from the pixel-flood decode-dimension finding (DOK-100025/100107, decoded-NRGBA allocation) and the go-exif thumbnail-length finding (DOK-100097/100108, JPEG EXIF make([]byte,uint32)); it is raw input-byte buffering during TIFF format detection.
Unvalidated fact: x/image buffer.fill uses io.ReadFull and stops at EOF, so the memory materialized equals the actual file bytes up to ifdOffset - the attacker must supply a genuinely GB-scale padded file, not merely declare a large ifdOffset.
Unvalidated fact: Whether a low-privilege user can place a GB-scale file in a previewable scope (needs Perm.Modify/upload or a pre-existing large TIFF) is deployment-dependent.
Unvalidated fact: Whether the ~2-4x-file-size transient spike times up to 4 concurrent decodes actually drives the shared process to OOM depends on host RAM / GOMEMLIMIT / kernel overcommit - not determinable from source.
Unvalidated fact: Whether an upstream proxy limit or storage quota caps effective file size below the required GB scale; the preview reads from the on-disk file, not the request body, so only disk/quota bounds it.
img/service.go:196-197 detectFormat: buf := &bytes.Buffer{}; r := io.TeeReader(in, buf) copies every byte read during detection into an in-memory buffer
img/service.go:199 image.DecodeConfig(r) reads from the TeeReader; TIFF magic dispatches to x/image tiff.DecodeConfig
img/service.go:205-208 the 10000x10000 guard is evaluated only after DecodeConfig returns and checks decoded imgConfig.Width/Height, so it cannot bound bytes buffered during detection
img/service.go:215 returns io.MultiReader(buf, in); the buffered prefix is handed to imaging.Decode (service.go:178), which re-decodes and re-buffers
x/image@v0.39.0/tiff/reader.go:474 ifdOffset := int64(d.byteOrder.Uint32(p[4:8])) reads the IFD offset from header bytes 4-7; :477 d.r.ReadAt(p[0:2], ifdOffset) triggers a read at the attacker offset
x/image@v0.39.0/tiff/buffer.go:60-68 newReaderAt wraps a non-ReaderAt reader; :21-47 buffer.fill sequentially io.ReadFull's data in 10MB chunks until the buffer holds end bytes, materializing all bytes 0..ifdOffset
http/preview.go:39 previewHandler requires Perm.Download; files/file.go:232,251-252 mime.TypeByExtension('.tiff') yields image/tiff; http/preview.go:86-90 FormatFromExtension passes for .tiff
img/service.go:148 + cmd/root.go:93 decode runs under sem.Acquire with imageProcessors default 4
Proven fact: detectFormat copies every byte read during format detection into an in-memory bytes.Buffer via io.TeeReader (service.go:196-197)
Proven fact: The 10000x10000 dimension guard runs only after image.DecodeConfig returns and checks only decoded Width/Height, not input bytes read during detection (service.go:199,205-208)
Proven fact: For TIFF, x/image reads the IFD at an attacker-controlled 32-bit ifdOffset (reader.go:474) via ReadAt (reader.go:477), forcing buffer.fill to hold all bytes 0..ifdOffset in memory (buffer.go:21-47)
Proven fact: The TeeReader (not an io.ReaderAt) defeats x/image's random-access fast path, so buffering occurs even though the underlying file is seekable (buffer.go:60-68)
Proven fact: A TIFF whose IFD declares ImageWidth=ImageLength=1 yields decoded dimensions that pass the guard, so the guard gives zero protection against the input-buffering path
Proven fact: imaging.Decode (service.go:178) re-decodes from io.MultiReader(buf, in) and the TIFF decoder re-buffers up to ifdOffset a second time
Proven fact: The path is reachable by an authenticated Perm.Download user previewing a .tiff (or image-extension) file with previews enabled (default); concurrency is bounded to imageProcessors (default 4)
Proven fact: This is a distinct sink from the pixel-flood decode-dimension finding (DOK-100025/100107) and the go-exif thumbnail-length finding (DOK-100097/100108)
Unvalidated fact: x/image buffer.fill uses io.ReadFull and stops at EOF, so materialized memory equals actual file bytes up to ifdOffset — the attacker must supply a genuinely GB-scale padded file, not merely declare a large ifdOffset
Unvalidated fact: Whether a low-privilege user can place a GB-scale file in a previewable scope (needs Perm.Modify/upload or a pre-existing large TIFF) is deployment-dependent
Unvalidated fact: Whether the ~2-4x-file-size transient spike times up to 4 concurrent decodes actually drives the process to OOM depends on host RAM / GOMEMLIMIT / kernel overcommit
Unvalidated fact: Whether an upstream proxy limit or storage quota caps effective file size below the required GB scale
img/service.go:196-199 (TeeReader into bytes.Buffer + image.DecodeConfig)
img/service.go:205-208 (dimension guard runs after DecodeConfig, cannot bound the buffering)
img/service.go:215 (io.MultiReader(buf,in) hands the whole-file buffer downstream; imaging.Decode:178 re-buffers)
golang.org/x/image@v0.39.0/tiff/reader.go:474,477 (attacker ifdOffset drives ReadAt)
golang.org/x/image@v0.39.0/tiff/buffer.go:21-35,60-68 (whole-input buffering of non-ReaderAt input)
img/service.go:148,153 + cmd/root.go:93 (buffering after sem.Acquire, bounded to workers=4)