Skip to content

v1.1.0

Latest

Choose a tag to compare

@MichaelSowah MichaelSowah released this 13 Jun 16:31
c1f6dee

[1.1.0] - 2026-06-13

Documentation

  • README and ImageProcessorIntegration aligned with actual behavior. Corrected the HTTP
    output method (stream(), not the nonexistent toResponse()); documented cached()'s
    read-or-populate + tag-invalidation semantics and the auto-key collision caveat; added the
    formats/security/metadata/watermark env vars (marking IMAGE_PNG_COMPRESSION inert and
    IMAGE_MAX_MEMORY/IMAGE_PROCESSING_TIMEOUT reserved/not yet enforced); documented the
    fail-closed remote-fetch, SSRF, watermark, and getID3 hardening; raised the documented
    framework requirement to >=1.55.0.

Added

  • Discovery-path regression test. Loads the provider through the framework's real
    extension-discovery dispatch (defs() pass-through, else services() via the DSL loader),
    guarding against typed Definition objects being returned from services() — a regression the
    existing Container::load()-based tests cannot catch.

Changed

  • Remote image fetching is now opt-in (fail-closed defaults). config/image.php defaults
    security.allowed_domains to an empty list and security.disable_external_urls to true, so no
    external host is fetched unless explicitly allow-listed via IMAGE_ALLOWED_DOMAINS (with
    IMAGE_DISABLE_EXTERNAL_URLS=false). The previous defaults (['*'] / false) were fail-open.

Security

  • SSRF hardening for remote image fetching. ImageProcessor::fromUrl() previously validated
    only the initial URL and then let file_get_contents follow redirects, so an allow-listed or
    open-redirect host could 302 to an internal address (e.g. cloud metadata 169.254.169.254)
    that was fetched unchecked; the URL blocklist was also substring-based and missed
    encoded / IPv6 / link-local forms. Redirects are now followed manually with every hop
    re-validated
    , and each target host is resolved and rejected when it maps to a
    private / loopback / link-local / reserved IP
    (isDisallowedIp, replacing substring
    blocklisting). Three review gaps in the original pass are closed too: the security-critical
    stream-context options (follow_location/max_redirects/ignore_errors) are forced after the
    caller-options merge so they can never be overridden; downloads are size-capped (new
    security.max_file_size, env IMAGE_REMOTE_MAX_FILESIZE, default 10M) via a length-capped
    read plus a Content-Length pre-check, preventing unbounded buffering; and make() — and
    therefore the image() helper — now routes http(s) URLs through the same hardened per-hop
    fetch
    instead of a validate-once-then-decode flow that was open to DNS-rebinding TOCTOU.
    Covered by RemoteFetchSafetyTest (IP classification incl. decimal/hex/octal literals,
    redirect resolution, option forcing, size caps, and the make() routing). Note: the core
    ImageSecurityValidator's fail-open defaults remain a separate cross-repo hardening item.
  • Watermark sources are confined to the configured directory. watermark() handed its path
    straight to the decoder, which reads any local path or stream-wrapper string
    (/etc/passwd, php://filter/..., http://...) — an arbitrary-file-read/SSRF vector.
    Watermark paths now reject URL/stream schemes outright and must canonicalize (realpath) inside
    paths.watermark_dir, with sibling-prefix-safe matching. Also fixed: centering a watermark
    with an odd dimension difference threw a TypeError (float coords into int parameters), and
    unknown-size uploads (PSR-7 getSize(): null) now skip the file-size cap instead of asserting
    a 0-byte size — decode-time dimension/integrity checks still apply.
  • getID3 analysis is capped and contained. Media metadata extraction passed uploads straight
    to getID3::analyze() — a pure-PHP container parser with a history of vulnerabilities on
    malformed input — with no size cap and no warning containment. Files are now size-capped before
    analysis (metadata.max_filesize, env MEDIA_METADATA_MAX_FILESIZE, default 500M — an abuse
    guard, not a typical-use limit), the parser runs with warnings/Throwables contained, and
    dimension/string values from container metadata are sanitized (control characters stripped,
    length-capped). Oversized or malformed uploads degrade to type-only metadata instead of
    exhausting a worker.

Fixed

  • cached() actually caches now. It was write-only — every "cached" pipeline re-decoded and
    re-encoded while filling entries nothing ever read. It now reads first and, on a hit, restores
    the cached image without re-encoding; caller-supplied keys with unsafe characters are hashed
    (closing Memcached delimiter-injection / file-cache traversal via raw keys), and the configured
    cache.tags are associated via the CacheStore tag API so grouped invalidation works. Note:
    auto-generated keys hash operations + dimensions but not source bytes — pass an explicit key
    when two sources could share both.
  • Per-format encoder config takes effect. IMAGE_JPEG_PROGRESSIVE (progressive JPEG) and
    IMAGE_WEBP_LOSSLESS (expressed as quality 100 — Intervention v4's only lossless WebP path)
    were documented but ignored by the encoder; they're now wired through both the factory and DI
    config paths. IMAGE_PNG_COMPRESSION remains inert — Intervention v4's PNG encoder has no
    compression parameter — and is now documented as such instead of silently dropped.
  • ImageProcessor factories no longer share one mutated instance. make()/fromUrl()/
    fromUpload()/create() all resolved the container's shared singleton, swapped its image
    state, and returned it — process two images in one request and the first reference silently
    pointed at the second image's bytes; under persistent workers (RoadRunner/Swoole) state bled
    across requests. Each factory call (and each ImageProcessorInterface container resolution —
    the binding is now non-shared) yields a fresh processor. The image() helper also forwards its
    explicit $context argument instead of discarding it in favor of the static default.
  • Boot compatibility with framework 1.55 — framework pin raised to ^1.55.0. The service
    provider declared its bindings via the DSL services() method but returned strongly-typed
    DefinitionInterface objects, which the framework's DSL service loader rejects
    ("Service '<id>' must be an array"). Under framework 1.55 this threw during boot in dev/test
    and silently dropped the bindings in production (so image() and the
    MediaProcessorInterface seam were never registered). The method is now defs(), the
    strongly-typed pass-through path that accepts DefinitionInterface objects. The defs()
    dispatch was introduced in framework v1.55.0 exactly (absent in 1.52–1.54, where this provider
    would register nothing at all), so the framework requirement rises from >=1.52.0 to
    >=1.55.0 (composer require-dev and extra.glueful.requires).