Skip to content

Arbitrary Local File Read and SSRF via Category EULA Rendered in Checkout Mail

Critical
snipe published GHSA-qmhc-p47c-6x75 Aug 24, 2026

Package

No package listed

Affected versions

<= 8.6.3

Patched versions

8.7.0

Description

SnipeModel::getEula() returned the raw eula_text string unchanged. Every checkout mail template (checkout-asset, checkout-accessory, checkout-component, checkout-consumable, checkout-license, bulk-asset-checkout-mail) then emitted that string into a Markdown mailable, whose HTML output was walked by eduardokum/laravel-mail-auto-embed. That library resolves every <img> server-side: file_get_contents() for local paths, curl for remote URLs (with CURLOPT_SSL_VERIFYPEER and CURLOPT_SSL_VERIFYHOST set to false, no scheme allowlist, no private-IP filter), and inlines the response bytes as a MIME attachment on the outgoing mail.

A low-privilege authenticated user with categories.create (or categories.edit), models.create, assets.create, and assets.checkout could set eula_text to a markdown-image or raw HTML <img> pointing at any file the web-server process can read (/var/www/html/.env, TLS private keys, backup archives, other tenants' uploads) or any URL the server can reach (cloud instance metadata, internal RFC1918 services, localhost listeners). Creating an asset in a category whose EULA carried the payload and checking it out to their own account delivered the file contents (or the URL's response body) to their mailbox as an attachment.

The primitive is not blind: the full contents come back as MIME attachments, giving the attacker complete read of the target. On a default deployment the .env disclosure includes APP_KEY, DB credentials, mail credentials, and any LDAP bind password. APP_KEY alone enables forgery of encrypted cookies and serialized payloads.

Companion to GHSA-f3vq-g24v-xc2g (checkout-acceptance note vector), which was fixed by registering BlockImagesMarkdownExtension on the mail CommonMark parser. That fix neutralizes markdown-syntax ![alt](url) images but does NOT neutralize raw HTML <img> tags, which CommonMark passes through as inline HTML. This report exposes both the raw-HTML variant AND a new source of user-controlled text feeding the same sink.

Severity

Critical. CVSS 3.1: 9.6

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N

  • Attack Complexity Low. Standard REST API sequence with the reporter's PoC completing in four HTTP calls.
  • Privileges Required Low. The seven granular permissions required (categories.view/create, models.view/create, assets.view/create/checkout) are the routine delegation for an IT asset clerk role. No superuser, no admin, no group assignment involved.
  • User Interaction None. The exfiltration channel is the checkout confirmation email, sent automatically by the application to the target of the checkout. Attacker checks the asset out to themselves.
  • Scope Changed. The read impact extends past the application boundary (secrets from .env compromise any subsequent system using those credentials; cloud metadata SSRF reaches the cloud control plane).
  • Confidentiality High. Arbitrary file read as the web-server user plus full-response SSRF.
  • Integrity High. APP_KEY disclosure enables forgery of encrypted payloads (session cookies on cookie driver, signed URLs, encrypted DB fields), which is a well-known chain to identity forgery and (on cookie-driver installs) RCE.

Reporter suggested a CVSS 4.0 vector: AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:H/SI:L/SA:N (9.1 Critical). Either vector lands in the Critical band; we've kept CVSS 3.1 for consistency with the rest of the advisory queue.

Weakness

  • CWE-73 (External Control of File Name or Path) as primary framing for the local-file read
  • CWE-918 (Server-Side Request Forgery) as primary framing for the remote SSRF variant
  • CWE-200 (Exposure of Sensitive Information) as an alternative frame covering the exfiltration channel

Affected Versions

<= 8.6.3 and all pre-release commits on develop prior to the fix commit below. SnipeModel::getEula() has returned the raw string for the entire lifetime of the getEula pattern. laravel-mail-auto-embed has been a bundled dependency since the initial mail refactor and defaults to enabled (config/mail-auto-embed.php reads MAIL_AUTO_EMBED with a true fallback; .env.example and .env.docker do not set the disable flag).

Attack Chain

Preconditions:

  1. Authenticated Snipe-IT session for a user delegated seven granular permissions (categories.view, categories.create OR categories.edit, models.view, models.create, assets.view, assets.create, assets.checkout). This is the routine permission set for a non-administrative asset clerk.
  2. MAIL_AUTO_EMBED at its default value (true, out of the box on every shipped install including official Docker images).
  3. A working outbound mail transport so the confirmation email is actually delivered.

Attack steps (reporter's four API calls):

  1. POST /api/v1/categories with eula_text = ![logo](/var/www/html/.env) (or <img src="/var/www/html/.env">) and use_default_eula = 0. Returns 200 and the new category id.
  2. POST /api/v1/models with the new category id. Returns 200.
  3. POST /api/v1/hardware with the new model id and a deployable status. Returns 200.
  4. POST /api/v1/hardware/{id}/checkout with assigned_user = <attacker_user_id>. Returns 200.

Server assembles CheckoutAssetMail, which loads getEula() (raw string) and passes it into the checkout-asset.blade.php template via {!! $eula !!}. Markdown mailable's CommonMark parser converts the markdown image (or passes the raw <img>) through to the final HTML. MessageSending listener from laravel-mail-auto-embed walks the HTML, sees the <img>, resolves /var/www/html/.env via file_get_contents, and inlines the bytes as a Content-Type: application/octet-stream attachment. Mail is delivered to the target (the attacker's own account) with APP_KEY, DB credentials, mail credentials, and LDAP bind password in the attachment.

Bulk-checkout variant amplifies to N distinct file reads in a single email by using BulkAssetCheckoutMail against N categories each with a different eula_text payload.

Root Cause

Five layers, each in tree:

  1. app/Models/Category.php, $fillable: eula_text is settable via categories.create / categories.edit. Legitimate feature.
  2. app/Models/SnipeModel.php::getEula() (pre-fix): returned $this->model->category->eula_text unchanged. Contrast with the safe app/Models/Category.php::getEula() which pipes through Helper::parseEscapedMarkedown (strip_tags + Parsedown safe mode).
  3. resources/views/mail/markdown/checkout-asset.blade.php (and five sibling checkout mail templates): {!! $eula !!} emits raw. Bulk template resources/views/mail/markdown/bulk-asset-checkout-mail.blade.php uses {{ $group->first()->eula }} which escapes HTML entities but does NOT escape markdown syntax (![alt](url) contains no HTML entities to escape, so it passes through and CommonMark parses it as an Image node).
  4. eduardokum/laravel-mail-auto-embed (v2.13, MAIL_AUTO_EMBED default true): the MessageSending listener walks the mail HTML, fetches every <img src=""> server-side, inlines the bytes. TLS verification hardcoded off in the vendored library.
  5. app/Listeners/CheckoutableListener.php at line 107: Mail::to(array_flatten($to))->send($toMail) sends to the target of the checkout, which the attacker set to themselves via assets.checkout.

Fix

Sanitize at the model boundary, before the string reaches any template. SnipeModel::getEula() now pipes the raw text through Helper::parseEscapedMarkedown (strip_tags + Parsedown safe mode) and additionally strips <img> from the Parsedown output. Both vectors close:

  • Raw HTML <img> in eula_text is killed by strip_tags before it reaches Parsedown.
  • Markdown-syntax ![alt](url) is Parsedown-converted to <img>, then the post-strip regex kills it before it reaches any template.

Legitimate markdown formatting (bold, italics, lists, paragraphs, links) is preserved by Parsedown safe mode.

resources/views/mail/markdown/bulk-asset-checkout-mail.blade.php switched from {{ $eula }} to {!! $eula !!} for the two eula outputs, since eula content is now guaranteed to be pre-sanitized HTML and the escape would show the HTML tags as literal text.

Two independent sanitizer layers now stand between attacker-controlled EULA text and the mail-auto-embed sink:

  1. SnipeModel::sanitizeEulaForRender at the model boundary (this fix).
  2. App\Mail\BlockImagesMarkdownExtension at the mail CommonMark parser (from GHSA-f3vq-g24v-xc2g).

We considered but did NOT change MAIL_AUTO_EMBED's default. Many installs run on closed networks and rely on the auto-embed for legitimate logo images, and flipping the default would break those workflows.

We did NOT try to patch TLS verification in the vendored laravel-mail-auto-embed library. The vendor hardcodes CURLOPT_SSL_VERIFYPEER = false and CURLOPT_SSL_VERIFYHOST = false, which is a vendor bug. Our sanitize step avoids the sink entirely, so the missing verification never gets the chance to matter.

Fix Commit

a434253

Regression Tests

tests/Feature/CheckoutAcceptances/EulaMailAutoEmbedInjectionTest.php, seven tests:

  • test_get_eula_strips_markdown_syntax_image_pointing_at_local_file seeds ![logo](/var/www/html/.env), asserts the model output contains no <img and no /var/www/html/.env.
  • test_get_eula_strips_raw_html_img_pointing_at_local_file seeds a literal <img src="/var/www/html/.env"> in eula_text, asserts the same.
  • test_get_eula_strips_markdown_syntax_image_pointing_at_ssrf_target seeds ![x](http://169.254.169.254/latest/meta-data/iam/security-credentials/), asserts the address is absent from output.
  • test_get_eula_strips_raw_html_img_pointing_at_loopback_ssrf_target covers a raw <img src="http://127.0.0.1:9999/secret"> payload.
  • test_get_eula_preserves_legitimate_markdown_formatting asserts **Terms** renders as <strong>Terms</strong> and lists become <li> items.
  • test_checkout_asset_mail_render_omits_poisoned_img_from_eula asserts the end-to-end mailable render contains no <img and no /var/www/html/.env when the underlying asset has a poisoned EULA.
  • test_checkout_asset_mail_render_omits_raw_html_img_from_eula covers the raw-HTML variant at the mailable-render level.

Credit

W1nterFr3ak (Chris Byron Otieno). Disclosed privately on 2026-07-31.

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N

CVE ID

No known CVE

Weaknesses

External Control of File Name or Path

The product allows user input to control or influence paths or file names that are used in filesystem operations. Learn more on MITRE.

Exposure of Sensitive Information to an Unauthorized Actor

The product exposes sensitive information to an actor that is not explicitly authorized to have access to that information. Learn more on MITRE.

Server-Side Request Forgery (SSRF)

The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination. Learn more on MITRE.

Credits