Skip to content

Harden v2.0.0: secure-by-default TLS, resource limits, redirect/XXE fixes#23

Merged
jaimeiniesta merged 2 commits into
masterfrom
2.0.0-security-hardening
Jun 2, 2026
Merged

Harden v2.0.0: secure-by-default TLS, resource limits, redirect/XXE fixes#23
jaimeiniesta merged 2 commits into
masterfrom
2.0.0-security-hardening

Conversation

@jaimeiniesta

Copy link
Copy Markdown
Owner

Pre-release security & code review hardening for v2.0.0, across both HTTP adapters. All changes are covered by unit tests (Req + HTTPoison) and verified against live hosts; mix test.adapters and mix test --include integration both pass with 0 failures.

Transport / TLS

  • Verify TLS certificates by default (both adapters). Pre-2.0 shipped hackney: [:insecure], which disabled verification on every HTTPS request. Added an explicit insecure: true opt-in for hosts with broken/self-signed certs.
  • Strip basic_auth when a redirect crosses origin (credentials no longer leak to redirect targets).
  • Match TLS-retry alerts structurally (real Mint {:tls_alert, {alert, _}} shape) and decouple the retry from the redirect budget; replaced the fabricated handshake mock.

Resource limits

  • Bounded gzip decompression via streaming safeInflate (real zip-bomb fix) plus a configurable max_body_size (default 100 MB, :infinity to disable) in both adapters → %Funkspector.Error{reason: :body_too_large}.

Resolver / API contract

  • A 3xx with no usable Location now returns an error tuple instead of raising FunctionClauseError; Location/Content-Encoding lookups are case-insensitive.
  • Redirect-limit exhaustion returns {:error, url, :too_many_redirects} instead of {:ok, url, <last 3xx>}.
  • Error tuples preserve the original requested URL; non-binary :contents:invalid_contents; tightened public @specs; stable {:adapter_error, msg} reason for reason-less exceptions.

Scrapers

  • SitemapScraper parses with dtd: :none — OTP-version-independent hardening against XXE, billion-laughs, and external-DTD fetch on attacker-controlled sitemaps.
  • PageScraper parses once, tolerates a nil body, resolves <link rel=canonical> against <base href>, and compares hosts case-insensitively.

Docs / hygiene

  • Corrected CVE attribution to the two advisories that actually affect hackney 1.21 — CVE-2026-47075 (CRLF) and CVE-2026-47076 (SSRF) — with NVD links; dropped the overstated "Alt-Svc/WebSocket/cluster" and "CVE-free" wording.
  • Added LICENSE (was declared MIT but missing from the tarball); documented insecure/max_body_size and the SSRF posture; UA version now derived from the app spec; removed stale .travis.yml; bumped CI actions and made CI run mix test.adapters.

Notes for review

  • max_body_size default 100 MB is a new (generous) default; :infinity disables.
  • A total-request-time bound is documented rather than coded — Req 0.5 doesn't forward Finch's request_timeout and raises on the unknown option.
  • Integration host moved to httpbingo.org with host-agnostic assertions.

…ixes

Pre-release security and code review hardening across both HTTP adapters.

Transport/TLS:
- Verify TLS certificates by default; add explicit `insecure: true` opt-in
  (both adapters). Previously `hackney: [:insecure]` disabled verification.
- Strip basic_auth on cross-origin redirects.
- Match TLS-retry alerts structurally and decouple the retry from the
  redirect budget; fix the fabricated handshake mock.

Resource limits:
- Bound gzip decompression with streaming safeInflate and add a configurable
  `max_body_size` (default 100 MB) in both adapters -> :body_too_large.

Resolver/contract:
- Return an error tuple (not a crash) for a 3xx without a usable Location;
  case-insensitive Location/Content-Encoding lookups.
- Error on redirect-limit exhaustion (:too_many_redirects) instead of an :ok
  tuple; preserve the original URL in error tuples.
- Non-binary :contents returns :invalid_contents; tighten public @specs;
  stable :adapter_error reason for reason-less exceptions.

Scrapers:
- SitemapScraper parses with dtd: :none (XXE / billion-laughs / external-DTD
  hardening, OTP-version-independent).
- PageScraper parses once, tolerates nil body, resolves canonical against
  <base href>, and compares hosts case-insensitively.

Docs/hygiene:
- Correct CVE attribution to the two advisories that actually affect hackney
  1.21 (CVE-2026-47075, CVE-2026-47076) with NVD links; drop overstated
  cluster/CVE-free claims.
- Add LICENSE; document insecure/max_body_size and SSRF posture; UA version
  from app spec; remove stale .travis.yml; bump CI actions and run both
  adapters in CI.

Tests:
- New adapter unit tests (mock Req.request/1, HTTPoison.get/3) covering TLS
  translation, body caps, and error mapping.
- Migrate integration host to httpbingo.org with host-agnostic assertions.
- mix test.adapters and mix test --include integration both pass.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Security-hardening pass for the upcoming v2.0.0 release, tightening transport defaults (TLS verification), redirect handling, resource limits, and XML/HTML parsing safety across both HTTP adapters (Req + HTTPoison), with expanded unit/integration coverage.

Changes:

  • Secure-by-default transport: TLS verification on by default, insecure: true opt-out, stricter TLS retry matching, and basic-auth stripping on cross-origin redirects.
  • Resource safety: configurable max_body_size plus bounded gzip decompression to mitigate zip bombs / memory exhaustion.
  • Parser & contract hardening: XXE/DTD refusal for sitemaps, case-insensitive header handling, safer redirect behavior and error tuples, and improved adapter error normalization.

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
test/support/mocked_connections.exs Updates TLS alert mock shape to match Mint/OTP.
test/support/integration_urls.exs Centralizes integration hosts; switches default httpbin host to httpbingo.
test/sitemap_scraper_test.exs Adds tests ensuring entity expansion / external DTD fetch do not occur.
test/resolver_test.exs Adds tests for max body size, redirect contract, auth stripping, TLS retry alert shapes.
test/page_scraper_test.exs Adds tests for host case-insensitivity, canonical resolution vs <base>, nil body tolerance.
test/integration/tls_integration_test.exs Verifies TLS is rejected by default and allowed only with insecure: true.
test/integration/resolver_integration_test.exs Makes redirect assertions host-agnostic; updates redirect-limit behavior expectation.
test/http/adapters/req_test.exs New adapter option-translation and error-mapping tests for Req adapter.
test/http/adapters/httpoison_test.exs New adapter option-translation tests for HTTPoison adapter.
test/funkspector_test.exs Adds regression test for non-binary :contents handling.
test/document_test.exs Ensures errors after redirects preserve the original requested URL.
ROADMAP.md Updates adapter reference to Req as default transport.
README.md Documents TLS default, insecure, max_body_size, and SSRF posture.
LICENSE Adds missing MIT license file.
lib/funkspector/utils.ex Clarifies URL validation is not an SSRF guard.
lib/funkspector/sitemap_scraper.ex Disables DTD/entity parsing (dtd: :none) to harden against XXE/billion-laughs.
lib/funkspector/resolver.ex Redirect contract changes; gzip bounded inflate; header casing normalization; TLS retry refinements; basic-auth stripping on cross-origin redirects.
lib/funkspector/page_scraper.ex Single-pass HTML parse; nil-body tolerance; canonical/base resolution fixes; host comparisons case-insensitive; security warning docs.
lib/funkspector/http/adapters/req.ex Implements secure-by-default TLS option layering; body-size checks; stable adapter error normalization.
lib/funkspector/http/adapters/httpoison.ex Implements insecure translation, body-size checks, and updated docs.
lib/funkspector/http/adapter.ex Updates adapter docs re: hackney CVE exposure wording.
lib/funkspector/document.ex Preserves original URL in error tuples and expands error contract types.
lib/funkspector.ex Adds unified error_reason type, new defaults (insecure, max_body_size), UA version from app spec, and invalid :contents handling.
CHANGELOG.md Documents new options and breaking behavior changes for v2.0.0.
.travis.yml Removes stale Travis config.
.github/workflows/ci.yml Updates GitHub Actions versions and runs mix test.adapters.
Comments suppressed due to low confidence (1)

lib/funkspector.ex:64

  • Funkspector.resolve/2 currently returns Resolver.resolve/2 verbatim, so on failures after redirects the returned URL will be the post-redirect URL. This contradicts the v2.0.0 contract noted in the changelog (“Error tuples report the original requested URL”).
  @spec resolve(String.t(), map()) ::
          {:ok, String.t(), Response.t()} | {:error, String.t(), error_reason()}
  def resolve(url, options \\ %{}) do
    options = Map.merge(default_options(), options)

    Resolver.resolve(url, options)
  end

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 65 to +68
@spec resolve(String.t() | any(), map()) ::
{:ok, String.t(), Response.t()}
| {:error, String.t() | any(), Response.t() | Error.t() | :invalid_url}
| {:error, String.t() | any(),
Response.t() | Error.t() | :invalid_url | :too_many_redirects}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 5f3946a. The gzip path now returns %Funkspector.Error{reason: :body_too_large} (unified with the adapter raw-body path), which is already covered by Error.t() in the spec union — so no separate atom is needed.

Comment on lines +230 to +234
if total > limit do
:too_large
else
safe_inflate(z, [], limit, [acc | output], total)
end

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified empirically — this does not crash. [acc | output] with a binary output produces a valid improper iolist (e.g. [[[] | "abc"] | "def"]), and both IO.iodata_to_binary/1 and IO.iodata_length/1 handle binary tails correctly. That said, switched to the proper-list form [acc, output] in 5f3946a for clarity.

Comment on lines +239 to +243
if total > limit do
:too_large
else
{:ok, IO.iodata_to_binary([acc | output])}
end

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the {:continue, ...} branch above — the improper list is valid iodata, but both branches now use [acc, output] (5f3946a).

Comment on lines +86 to +99
defp request_headers_and_options(options) do
headers = request_headers(options)

options =
options
|> translate_insecure()
|> Map.delete(:user_agent)
|> Map.delete(:basic_auth)
|> Map.delete(:adapter)
|> Map.delete(:contents)
|> Map.delete(:connect_options)
|> Map.delete(:insecure)

{headers, options}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5f3946a:max_body_size is now deleted before HTTPoison.get/3 (hackney silently ignored it, so no runtime error today, but it should not be forwarded). Added a regression test asserting it is not in the forwarded options.

Comment thread lib/funkspector.ex Outdated
Comment on lines 88 to 90
@spec page_scrape(String.t(), map()) ::
{:ok, Document.t()} | {:error, String.t() | any(), any()}
{:ok, Document.t()} | {:error, String.t(), error_reason()}
def page_scrape(url, options \\ %{}) do

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5f3946a. These functions intentionally echo back invalid non-binary input in the {:error, url, :invalid_url} tuple (covered by tests), so the URL positions are loosened to String.t() | any(). The precise error_reason type is kept.

Comment thread lib/funkspector.ex Outdated
Comment on lines 112 to 114
@spec sitemap_scrape(String.t(), map()) ::
{:ok, Document.t()} | {:error, String.t() | any(), any()}
{:ok, Document.t()} | {:error, String.t(), error_reason()}
def sitemap_scrape(url, options \\ %{}) do

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5f3946a (same change as page_scrape/2) — URL positions loosened to String.t() | any().

Comment thread lib/funkspector.ex Outdated
Comment on lines 136 to 138
@spec text_sitemap_scrape(String.t(), map()) ::
{:ok, Document.t()} | {:error, String.t() | any(), any()}
{:ok, Document.t()} | {:error, String.t(), error_reason()}
def text_sitemap_scrape(url, options \\ %{}) do

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5f3946a (same change as page_scrape/2) — URL positions loosened to String.t() | any().

Comment thread README.md
Comment on lines +119 to +122
`max_body_size` (default 100 MB) bounds both the raw response body and gzip
decompression, protecting against memory exhaustion and decompression bombs
when scraping untrusted URLs. Oversized responses return
`%Funkspector.Error{reason: :body_too_large}`. Set `:infinity` to disable:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 5f3946a by unifying the code to the documented shape: the resolver gzip path now returns %Funkspector.Error{reason: :body_too_large} (matching the adapter raw-body path), so the docs are accurate.

Comment thread CHANGELOG.md
Comment on lines +17 to 18
- `:max_body_size` option (default 100 MB) that bounds both the raw response body and gzip decompression; oversized responses return `%Funkspector.Error{reason: :body_too_large}`. Set `:infinity` to disable.
- Mix aliases: `mix test.httpoison` (run the suite against the HTTPoison adapter) and `mix test.adapters` (run both back-to-back).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 5f3946a — the resolver now returns %Funkspector.Error{reason: :body_too_large} for the gzip-decompression cap too, matching this changelog entry.

…p forwarded option

- Unify the oversized-body error to %Funkspector.Error{reason: :body_too_large}
  in the resolver gzip path (matches the adapter raw-body path and the docs),
  instead of a bare atom.
- Loosen public @specs to allow non-binary URL input/echo (page_scrape etc.
  return invalid non-string input unchanged in the error tuple); keep the
  precise error_reason type. Drop redundant :body_too_large atom (now Error.t()).
- HTTPoison adapter: stop forwarding the funkspector-level :max_body_size key
  to HTTPoison.get/3; add a regression test.
- Use a proper-list accumulator in the bounded gunzip (clearer; the prior
  improper iolist was valid but flagged).
- Scope the CHANGELOG 'original URL' note to the scrape functions; resolve/2
  intentionally reports the URL that failed.
@jaimeiniesta

Copy link
Copy Markdown
Owner Author

@copilot can you take another look?

Copilot AI commented Jun 2, 2026

Copy link
Copy Markdown

@copilot can you take another look?

I re-reviewed the latest commit (5f3946a) and the prior review threads look fully addressed. Current CI runs for this branch are green (latest CI push/pull_request runs succeeded), and I don’t see any additional changes needed from my side.

@jaimeiniesta
jaimeiniesta merged commit cd1d040 into master Jun 2, 2026
15 checks passed
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.

3 participants