Skip to content

feat(render): image URL fetch + SSRF hardening - #81

Merged
ajianaz merged 8 commits into
developfrom
feat/render-bg-image-url
Sep 4, 2026
Merged

feat(render): image URL fetch + SSRF hardening#81
ajianaz merged 8 commits into
developfrom
feat/render-bg-image-url

Conversation

@ajianaz

@ajianaz ajianaz commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Closes #74 (item 1; items 2–3 landed via #80)

What

  1. Images accept https:// URLsimage_to_data_uri fetches remote images: 10 s timeout, 10 MB cap enforced during streaming (Read::take), no redirects, DNS pinned to the validated address, no_proxy().
  2. SSRF hardening via ImagePolicy { allow_private, allow_local }, threaded explicitly through render_template(_data)render_slide_to_pngprocess_templateimage_to_data_uri + |b64 filter:
    • Server default = SECURE: https-only public URLs (loopback / RFC1918 / link-local incl. cloud metadata / CGNAT / benchmark / IPv4-compatible+v6 special ranges rejected; connection pinned so DNS rebinding cannot redirect it) and local filesystem paths rejected outright — no file-read exfiltration through render data
    • Server opt-ins: --allow-private-images, --allow-local-image-paths
    • CLI = ImagePolicy::UNRESTRICTED (local, user-driven)
    • Fetch failures log details server-side and return a generic message — no existence oracle or byte-count leak to clients
  3. reqwest moved to a real dependency (blocking + json + rustls-tls); server render wrapped in spawn_blocking.

Why

#74 item 1 (URL input without manual download) + the fetch surface must not turn cosy serve (which renders attacker-controlled JSON) into an SSRF/file-read primitive. Full hardening rationale and the 3 completed security-review rounds are documented on the superseded #78.

Testing

  • 143 tests green, including: 23-entry IP classification table (loopback/RFC1918/link-local/CGNAT/IPv4-compatible/mapped-v6), private-target block, redirect rejection in both modes, https-only default, loopback hostname block, local-path policy rejection, oversized Content-Length, scheme rejection
  • E2E: CLI renders URL background (visually verified); server default blocks loopback URL and /etc/hostname (warn logged, render proceeds without image); both opt-in flags restore access

…ixes

- image_to_data_uri now accepts http(s):// URLs: fetched with a 10s
  timeout and a 10 MB size cap; mime from Content-Type with URL
  extension fallback (ref: #74 item 1)
- blog-hero: new brand field bg_image_overlay (0.0-1.0, default 0.7)
  controls the gradient overlay opacity on top of bg_image, replacing
  the hardcoded 0.7 (ref: #74 item 2)
- server render handler wraps blocking render in spawn_blocking so
  remote fetches never block the async runtime
- docs: correct --scale default (2.0, not 1.0), document the 2x output
  default and URL support, add bg_image_opacity dark-artwork guidance
  (ref: #74 item 3)

Closes #74
CodeCora review on this PR flagged an SSRF vector: cosy serve renders
attacker-controlled JSON, and image_to_data_uri would GET any
http(s):// URL — including loopback, RFC1918, and cloud-metadata
link-local targets — while default redirect following could bypass
naive host filters, and fetch errors echoed internal details.

- Resolve the URL host and require a globally routable address before
  connecting; re-validate scheme + host on every redirect hop (max 5)
- IPv4-mapped IPv6, CGNAT, benchmark, documentation, multicast ranges
  are rejected too
- deny by default: CLI render opts in locally; cosy serve only via the
  explicit --allow-private-images flag
- fetch failures now log the cause and return a generic 'failed to load
  remote image' instead of echoing URLs and byte counts
- 5 new tests: private-target block, redirect-to-metadata block,
  non-http scheme rejection, IP classification table, generic errors
Addresses the second CodeCora block round in full:

- DNS rebinding: validate_url_host resolved independently of reqwest's
  connection resolution, so a hostile domain could rotate to a private
  IP between the two lookups. The fetch now pins the connection to the
  validated address via ClientBuilder::resolve — no second resolution.
- Unbounded body: response.bytes() buffered everything before the size
  check; chunked encoding bypassed the Content-Length pre-check. The
  body is now streamed via take(MAX + 1) so memory is capped during
  transfer, not after.
- Redirects are removed entirely (Policy::none + explicit 3xx error):
  each hop previously needed its own pin, which is not expressible;
  a redirect response is now an error in all modes.
- https-only by default: plain http:// requires allow_private (local
  CLI or the server's --allow-private-images), per the 'use HTTPS for
  external connections' finding.
- Tests updated: redirects not followed in either mode, https-only
  default, loopback hostname blocked, plus the existing SSRF table.
::a.b.c.d (IPv4-compatible, first 96 bits zero) now falls through to
the embedded IPv4 check alongside ::ffff:a.b.c.d — ::127.0.0.1 and
::169.254.169.254 were previously treated as public IPv6.
reqwest honors HTTP(S)_PROXY/ALL_PROXY env vars by default; a proxy
would perform its own DNS and connect to an unvalidated target,
defeating the validated-address pin. Force direct connections.
CodeCora round-3 finding: in server mode an attacker-supplied
bg_image like '/etc/passwd' was read from disk and base64-embedded
into the rendered PNG — a pre-existing arbitrary-file-read vector
the URL fetch work made visible.

Introduce ImagePolicy { allow_private, allow_local } threaded
explicitly through render_template(_data) -> render_slide_to_png ->
process_template -> image_to_data_uri (and the |b64 filter):

- server default = ImagePolicy::SECURE: https-only public URLs, and
  local filesystem paths are rejected outright
- new --allow-local-image-paths opt-in for the server (e.g. trusted
  internal pipeline); --allow-private-images unchanged
- standalone CLI uses ImagePolicy::UNRESTRICTED (local, user-driven)

Replaces the process-global atomics with an explicit parameter so the
policy is visible in signatures and tests need no global mutation.
New test: secure policy rejects local paths; e2e verified server
default rejects '/etc/hostname' (warn logged, render proceeds without
the image) while the opt-in embeds it.
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

🔍 Cora AI Code Review

No issues found. Code looks good!


Review powered by cora-code · BYOK · MIT

@ajianaz
ajianaz merged commit dd65906 into develop Sep 4, 2026
12 checks passed
@ajianaz
ajianaz deleted the feat/render-bg-image-url branch September 4, 2026 00:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(render): flexible background image handling (URL input, configurable overlay, opacity presets)

1 participant