Skip to content

Replica: fix(message-parser): match parts on mime_type so multipart/related survives tracking#78

Open
lucaforni wants to merge 16 commits into
main-modalsourcefrom
cl77-postal-fix/message-parser-related-type-param
Open

Replica: fix(message-parser): match parts on mime_type so multipart/related survives tracking#78
lucaforni wants to merge 16 commits into
main-modalsourcefrom
cl77-postal-fix/message-parser-related-type-param

Conversation

@lucaforni

Copy link
Copy Markdown

Questa PR replica la PR originale: postalserver#3597

Autore originale: @cl77
Branch originale: fix/message-parser-related-type-param
Repository originale: cl77/postal


Summary

MessageParser#parse_parts matches parts with case part.content_type, which returns the full Content-Type header value including parameters. A multipart/related container that carries the RFC 2387 type="text/html" parameter - which PHPMailer and other mainstream senders emit for HTML mails with inline CID images - therefore matches the first when /text/html/ branch instead of the multipart branch.

The consequences for any tracked message with that structure (multipart/alternative containing multipart/related with inline images):

  • the related container's raw source (child part headers, boundaries, base64 payloads) is decoded and reassigned as a text body,
  • charset = UTF-8 is stamped onto the container header,
  • on re-serialization the Mail gem rebuilds the container as multipart/alternative, with the raw source as a huge text part and the original children appended as siblings.

Since the images then live in an alternative container rather than a related one, no mail client resolves the cid: references anymore - the message arrives without inline images, and the tracking pixel never reaches the real HTML part either. We hit this in production sending via the send/raw API with open tracking enabled.

Fix

Match on part.mime_type, the bare MIME type without parameters. This is consistent with generate(), which already inspects @mail.mime_type for non-multipart messages. No behavior change for any part whose Content-Type carries no parameters that collide with the matchers.

Repro / validation

Standalone reproduction with the mail gem (structure of the re-serialized message after parse_parts):

Before (case part.content_type):
multipart/alternative > [text/plain, multipart/alternative > [text/plain (raw container source), text/html, image/png]] - related container destroyed, Content-ID lost, no pixel in HTML.

After (case part.mime_type):
multipart/alternative > [text/plain, multipart/related > [text/html (pixel injected), image/png with Content-ID]] - structure intact.

A regression spec is included: it feeds a multipart/alternative message with a nested multipart/related; type="text/html" container through the parser with a track domain enabled and asserts the related container, its image part's Content-ID and the injected tracking pixel in the HTML leaf all survive.

adamcooke and others added 16 commits February 1, 2026 14:48
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
The app-wide CSP already blocks inline script execution, but the HTML
preview iframe for a stored email was same-origin and un-sandboxed, and
the html_raw response had no per-action hardening. Add a sandbox on the
iframe and tighten the CSP on html_raw to script-src 'none' with
nosniff and no-referrer so the preview has defence in depth against a
future CSP bypass or regression.

Relates to GHSA-f6g9-8555-cw28.
The /img/<server>/<message> endpoint accepted a src=<url> query
parameter and proxied the body of that URL back to the caller. Nothing
in the codebase ever produces a src= parameter — the parser only
inserts a plain tracking pixel and rewrites href links — so this branch
is dead code inherited from the original AppMail import.

Drop the src branch: requests with src now return 400. The no-src path
that serves the tracking pixel and records loads is unchanged, and a
spec covers both the pixel-serving path and the removed branch.
The endpoint and domain option helpers interpolated model attributes
straight into an HTML string before marking the whole buffer html_safe.
Wrap the interpolations in h() so untrusted attributes can't break out
of the surrounding tag.

Also stop the helpers glob in rails_helper from eagerly requiring
_spec.rb files so helper specs can live under spec/helpers/, and add a
small application helper spec covering the escape behaviour.
url_with_return_to only checked that return_to started with a forward
slash, which also allowed protocol-relative values like //host and
/\host. Rails 7.1 already refuses to follow those via redirect_to, so
the user just saw a 500. Reject the same shapes in the helper instead
so we fall back to the default URL cleanly.

Adds a sessions request spec covering the rejected shapes plus the
happy-path relative redirect.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…rfpg-3xr5)

The Legacy API message lookup endpoints parsed the request body as JSON and
passed the `id` parameter straight through to the message database. A JSON
object supplied for `id` arrived as a Ruby Hash and was used as a raw set of
SQL `WHERE` conditions. `hash_to_sql` interpolated each Hash key directly
inside backtick identifier quoting while escaping only the value, so a key
containing a backtick could break out of the identifier and inject arbitrary
SQL into the SELECT (blind, time-based) against the message database.

Fixes:

- Escape all identifiers (columns, tables, database names) through a new
  `escape_identifier` helper that wraps in backticks and doubles embedded
  backticks. Applied across hash_to_sql, select, insert, insert_multi,
  update and delete so no caller can inject via an identifier.
- Validate the Legacy API `id` parameter at the controller boundary: reject
  any non-scalar value before it reaches the database and coerce it to an
  integer. Internal Hash-based lookups (e.g. tracking middleware) are
  unaffected.

Adds regression tests at the unit (hash_to_sql / escape_identifier) and
request (legacy messages/deliveries) levels.
Webhook and HTTP message endpoint deliveries both flow through
Postal::HTTP, which parsed the user-supplied URL and connected to its
host with no address validation. An authenticated user could point a
webhook or endpoint at a private, loopback or link-local address (e.g.
127.0.0.1, 169.254.169.254 cloud metadata, RFC1918 hosts) and make the
server issue requests into its own internal network.

Add Postal::HTTP::AddressGuard, which resolves the destination host and
rejects private/loopback/link-local/reserved/multicast IPv4 and IPv6
addresses, then pins the connection to the validated address so it cannot
be redirected via a DNS-rebinding race. Administrators can permit specific
destinations via the new postal.allowed_request_destinations config option
(hostnames or IP/CIDR ranges).

Address selection only uses families this server can actually reach so we
do not pin to an IPv6 address on a host without IPv6 connectivity; IPv4 is
preferred for predictability. HTTPEndpoint now validates that its URL is a
well-formed HTTP(S) URL with a host.
The spec relied on the test machine having real IPv6 connectivity,
which GitHub Actions runners do not have.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…rvives tracking

MessageParser#parse_parts matched on part.content_type, which is the full header value including parameters. A multipart/related container carrying the RFC 2387 type="text/html" parameter (emitted by PHPMailer and other senders for HTML mails with inline CID images) therefore matched the /text\/html/ branch first: the container's raw source was decoded and reassigned as a text body, and re-serialization rebuilt it as a broken multipart/alternative whose inline images no client renders.

Matching on part.mime_type (bare MIME type, no parameters) routes such containers into the multipart branch, consistent with how generate() already inspects @mail.mime_type for non-multipart messages. Adds a regression spec covering the nested related container with a type parameter.
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