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  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:
- 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.
MAIL_AUTO_EMBED at its default value (true, out of the box on every shipped install including official Docker images).
- A working outbound mail transport so the confirmation email is actually delivered.
Attack steps (reporter's four API calls):
POST /api/v1/categories with eula_text =  (or <img src="/var/www/html/.env">) and use_default_eula = 0. Returns 200 and the new category id.
POST /api/v1/models with the new category id. Returns 200.
POST /api/v1/hardware with the new model id and a deployable status. Returns 200.
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:
app/Models/Category.php, $fillable: eula_text is settable via categories.create / categories.edit. Legitimate feature.
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).
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 ( contains no HTML entities to escape, so it passes through and CommonMark parses it as an Image node).
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.
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
 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:
SnipeModel::sanitizeEulaForRender at the model boundary (this fix).
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 , 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 , 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.
SnipeModel::getEula()returned the raweula_textstring 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 byeduardokum/laravel-mail-auto-embed. That library resolves every<img>server-side:file_get_contents()for local paths,curlfor remote URLs (withCURLOPT_SSL_VERIFYPEERandCURLOPT_SSL_VERIFYHOSTset 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(orcategories.edit),models.create,assets.create, andassets.checkoutcould seteula_textto 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
.envdisclosure includesAPP_KEY, DB credentials, mail credentials, and any LDAP bind password.APP_KEYalone enables forgery of encrypted cookies and serialized payloads.Companion to GHSA-f3vq-g24v-xc2g (checkout-acceptance note vector), which was fixed by registering
BlockImagesMarkdownExtensionon the mail CommonMark parser. That fix neutralizes markdown-syntaximages 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:Ncategories.view/create,models.view/create,assets.view/create/checkout) are the routine delegation for an IT asset clerk role. Nosuperuser, noadmin, no group assignment involved..envcompromise any subsequent system using those credentials; cloud metadata SSRF reaches the cloud control plane).APP_KEYdisclosure enables forgery of encrypted payloads (session cookies oncookiedriver, 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
Affected Versions
<= 8.6.3and all pre-release commits ondevelopprior to the fix commit below.SnipeModel::getEula()has returned the raw string for the entire lifetime of the getEula pattern.laravel-mail-auto-embedhas been a bundled dependency since the initial mail refactor and defaults to enabled (config/mail-auto-embed.phpreadsMAIL_AUTO_EMBEDwith atruefallback;.env.exampleand.env.dockerdo not set the disable flag).Attack Chain
Preconditions:
categories.view,categories.createORcategories.edit,models.view,models.create,assets.view,assets.create,assets.checkout). This is the routine permission set for a non-administrative asset clerk.MAIL_AUTO_EMBEDat its default value (true, out of the box on every shipped install including official Docker images).Attack steps (reporter's four API calls):
POST /api/v1/categorieswitheula_text = (or<img src="/var/www/html/.env">) anduse_default_eula = 0. Returns 200 and the new category id.POST /api/v1/modelswith the new category id. Returns 200.POST /api/v1/hardwarewith the new model id and a deployable status. Returns 200.POST /api/v1/hardware/{id}/checkoutwithassigned_user = <attacker_user_id>. Returns 200.Server assembles
CheckoutAssetMail, which loadsgetEula()(raw string) and passes it into thecheckout-asset.blade.phptemplate via{!! $eula !!}. Markdown mailable's CommonMark parser converts the markdown image (or passes the raw<img>) through to the final HTML.MessageSendinglistener fromlaravel-mail-auto-embedwalks the HTML, sees the<img>, resolves/var/www/html/.envviafile_get_contents, and inlines the bytes as aContent-Type: application/octet-streamattachment. Mail is delivered to the target (the attacker's own account) withAPP_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
BulkAssetCheckoutMailagainst N categories each with a differenteula_textpayload.Root Cause
Five layers, each in tree:
app/Models/Category.php,$fillable:eula_textis settable viacategories.create/categories.edit. Legitimate feature.app/Models/SnipeModel.php::getEula()(pre-fix): returned$this->model->category->eula_textunchanged. Contrast with the safeapp/Models/Category.php::getEula()which pipes throughHelper::parseEscapedMarkedown(strip_tags+ Parsedown safe mode).resources/views/mail/markdown/checkout-asset.blade.php(and five sibling checkout mail templates):{!! $eula !!}emits raw. Bulk templateresources/views/mail/markdown/bulk-asset-checkout-mail.blade.phpuses{{ $group->first()->eula }}which escapes HTML entities but does NOT escape markdown syntax (contains no HTML entities to escape, so it passes through and CommonMark parses it as an Image node).eduardokum/laravel-mail-auto-embed(v2.13,MAIL_AUTO_EMBEDdefault true): theMessageSendinglistener walks the mail HTML, fetches every<img src="">server-side, inlines the bytes. TLS verification hardcoded off in the vendored library.app/Listeners/CheckoutableListener.phpat line 107:Mail::to(array_flatten($to))->send($toMail)sends to the target of the checkout, which the attacker set to themselves viaassets.checkout.Fix
Sanitize at the model boundary, before the string reaches any template.
SnipeModel::getEula()now pipes the raw text throughHelper::parseEscapedMarkedown(strip_tags+ Parsedown safe mode) and additionally strips<img>from the Parsedown output. Both vectors close:<img>ineula_textis killed bystrip_tagsbefore it reaches Parsedown.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.phpswitched 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:
SnipeModel::sanitizeEulaForRenderat the model boundary (this fix).App\Mail\BlockImagesMarkdownExtensionat 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-embedlibrary. The vendor hardcodesCURLOPT_SSL_VERIFYPEER = falseandCURLOPT_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_fileseeds, asserts the model output contains no<imgand no/var/www/html/.env.test_get_eula_strips_raw_html_img_pointing_at_local_fileseeds a literal<img src="/var/www/html/.env">in eula_text, asserts the same.test_get_eula_strips_markdown_syntax_image_pointing_at_ssrf_targetseeds, asserts the address is absent from output.test_get_eula_strips_raw_html_img_pointing_at_loopback_ssrf_targetcovers a raw<img src="http://127.0.0.1:9999/secret">payload.test_get_eula_preserves_legitimate_markdown_formattingasserts**Terms**renders as<strong>Terms</strong>and lists become<li>items.test_checkout_asset_mail_render_omits_poisoned_img_from_eulaasserts the end-to-end mailable render contains no<imgand no/var/www/html/.envwhen the underlying asset has a poisoned EULA.test_checkout_asset_mail_render_omits_raw_html_img_from_eulacovers the raw-HTML variant at the mailable-render level.Credit
W1nterFr3ak (Chris Byron Otieno). Disclosed privately on 2026-07-31.