Closed (fixed)
Project:
Mailer Plus (DSM+)
Version:
2.x-dev
Component:
Code
Priority:
Normal
Category:
Feature request
Assigned:
Unassigned
Reporter:
Created:
4 Jun 2022 at 16:29 UTC
Updated:
26 May 2026 at 16:10 UTC
Jump to comment: Most recent, Most recent file
Comments
Comment #3
pstewart commentedI've done an initial implementation using the general approach from 3261807#20, but adapted to be closer to the way the SwiftMailer modules handles inline images:
* The inlining only applies to URIs starting with
image:* Paths are resolved in the same way as in SwiftMailer, i.e. ny leading slash is stripped, if the path parses as a URI then is passed through unchanged, otherwise the path is passed through
realpath* Responsibility for generating cids lies with the adjuster, as the interface for
embedFromPathallows the cid to be specified, so adjuster and builder implementations should be free to implement in whatever way is appropriate for their use case, rather than cids being dictated by theEmailclass. I've therefore kept the UUID CID generation in the adjuster (maybe this could be spun out to agenerateCidmethod inBaseEmailTraitto provide a general facility for implementations that don't want to manage CIDs themselves)* The
DOMDocumenttechnique from the original comment does not cope with HTML5, so has been replaced by apreg_match_all+str_replaceimplementation derived from the SwiftMailer module's technique. This has the side-benefit of catching any double-quote delmitedimage:URI attribute, so should also work for responsive image srcsets etc.I've tested this by adding the new adjuster to my default policy, and verified emails being rendered with a
email.html.twigconverted from aswiftmailer.html.twigwithimage:URIs behave as expected.Comment #5
adamps commentedGreat many thanks for the patch. Here are some comments
preg_match_all()is not the best way to parse HTML - please use$dom->getElementsByTagName('img')as in the patch you started from. Then use$img->setAttribute()instead ofstr_replace. Get rid of $processed_images variable.embedFromPath($path)returning a CID. The function supports passing the same $path and returns the same CID. The CID counts up numerically it doesn't need to be a UUID.Comment #6
pstewart commentedHi Adam,
Just want to make sure on a few things before I have another go at this patch:
1. While
preg_match_all()is not ideal, the DOM Document technique has a deal breaker in that it quite definitely does not support HTML5, and none of the workarounds look to be viable (see https://stackoverflow.com/questions/10712503/how-to-make-html5-work-with... ). Perhaps there's a way we can interact with the render array before we render, but that might still present difficulties when embeds are being done directly in twig templates?2. Are you recommending we change the interface so that
embedFromPathreturns the CID rather thanthis? I was working on the assumption that we were following the underlying API as closely as possible, soembedFromPathwould returnthisto support method chaining. I see what you mean about UUIDs though, I'll refactor this to use a simple counter.3. The
realpath()usage here is only as a fallback for if the matched path doesn't parse as a URL (i.e. can definitely be handled by known stream wrappers). If the path can't be handled by known stream wrappers then it can only relate to the the filesystem, sorealpath()is appropriate to use inside theif, as ultimately we're going to be passing off to Symfony directly after. However if we're confident we'll never get there then we could just drop the if statement entirely.Comment #7
adamps commentedThanks for the follow up.
1. Core uses
\DOMDocumentin 4 places includingHtml::transformRootRelativeUrlsToAbsoluteThis module usesCssToInlineStyleswhich uses\DOMDocument. So for the moment it seems to be most consistent with what we already do.preg_match_all()could be fooled by comments, whitespace, etc, so that's no good.2. Yes that was my idea to change the interface - we have done this in other places hence
BaseEmailTraitwith a protected inner\Symfony\Component\Mime\Emailobject. You are right we would lose chaining however I can't see a better way. We can't safely put a counter inInlineImagesEmailAdjusterbecause the CID might clash with another class that also inserts inline images. However theEmailclass can use a simple counter because it can see all the CIDs and images, and it can even return the same CID if the same image is used to avoid transporting the embedded image twice.3. I think we can drop both if statements and require a URL scheme. Mailing of local files without using stream wrappers seems like something we don't want to encourage for security as it makes it hard to prevent emailing of a sensitive/private image. When we later write security checking (I thought I raised an issue but I can't see it so maybe I didn't) it could be based on checking the URL scheme. Of course people can still use
file:in their email, however we can write a policy to block it.Comment #8
adamps commentedInteresting, /vendor/symfony/mime/Email.php uses
preg_match_all(). I'm still not sure if it's robust in all unusual cases, and it feels safer to copy what Drupal does.In existing
attachFromPath(), I think the$nameparameter seems to default to none. What we can say is that if there is a name, it must be unique among all attachments. Please can you fix to something like this?* (optional) The file name, which if set must be unique among all attachments.One way to avoid changing the interface of
embedFromPath()is to add a new methodgetUniqueName(string $path). Downside is that it makes the calling code more complex adding a line of code.Probably I still prefer to change the interface:
* (optional) The file name, which must be unique among all attachments. Leave blank to generate a unique name.and
* @return string The file name.Comment #10
metallized commentedHey i did rebase of this MR and did try to fix the comments made by @AdamPS.
I did't implement the CID counter and instead i relied on the
getContentId()method from DataPartComment #11
adamps commentedGreat thanks. The tests are failing with a syntax error so this needs work.
Comment #12
metallized commentedFixed linter issues.
Comment #13
adamps commentedThanks @metallized, that's some more good ideas.
It's a good reminder to look at
image:, which as far as I can see is specific to swiftmailer. It could be good to support it for back-compatibility however I would see it as deprecated. I propose we should get the main function of this issue working here, and raise a separate issue forimage:.This issue #1333730: [Meta] PHP DOM (libxml2) misinterprets HTML5 covers HTML5 support in core, and it proposes to use html5-php. The interface seems close to
\DOMDocument, so sticking with\DOMDocumentis likely the best direction for now.Details comments are in the MR.
Comment #14
metallized commented@AdamPS, i'll wait your answer to commit the changes.
Comment #15
adamps commentedThe UTF-8 problem seems unpleasant, thanks for debugging it. There seems to a choice of exactly how to solve it, see discussion. Most of the options seem a bit hacky, with possible problems.
1) According to #3342481: Deprecated function: mb_convert_encoding(): Handling HTML entities via mbstring is deprecated, the
mb_convert_encoding()option could be deprecated in PHP 8.2.2) In
tijsverkoyen/css-to-inline-styles(which is already a dependency of this module), increateDomDocumentFromHtml()it usesThis seems even more hacky than most options however I guess there is a precedent for it.
3) We could potentially avoid all the problems and create a future-proof solution using html5-php, as mentioned in #13.
Comment #16
adamps commentedFor the other question about this code:
Basically like that, however if the caller passed
$namewe should use it. So I would say more like this, I think better thanrandom_bytes()is to use a hash of$path.Comment #17
adamps commentedI just spotted one more thing. The original patch had $
processed_imagesto avoid embedding the same image twice. We still need that I think - it can be map from filename to cid. If the image was already processed, then we skip the call toembedFromPath()but still callsetAttribute().Comment #18
metallized commentedI think all gets fixed, about #17, i think is not necesary cause in first place we need to get the
cidfromuriso we can create a$processedImage, i think we need a helper method or function to create thecidstring so we skip the call toSymfony\Component\Mime\Email::embedFromPath.Comment #19
adamps commentedGreat thanks I'll take a look
Comment #20
adamps commentedSo HTML5 solved all the problems??
Interesting, core has
HTML::load()andHtml::serialize. I guess that would be the alternative if we find problems with the HTML5 library.Comment #21
metallized commentedYeah solved all problems.
Let me replace the use of direct HTML5 for Core HTML.
Comment #22
metallized commentedAll done, also working encoding.
Comment #23
metallized commentedComment #24
metallized commented@AdamPS do you need something more, can we update this?
Comment #25
adamps commentedSorry I haven't forgotten, just been a bit busy. I'll get to it soon.
Comment #26
adamps commentedGenerally it's looking good thanks. This is a key issue and it's important that we do all we can to get it right😃. I read through the past comments and here's what I spotted:
1) #17/#18 seems to be still unresolved.
embedFromPath()can keep a map with key = path and value = name. If the key already exists then skip the call to$this->inner->embedFromPath.2) Originally I suggested a CID counter. Unfortunately later I forgot about this idea, and suggested sha1. The counter seems better as it's faster and generates shorter names. Any reason why we can't use the original counter idea?
3) We should have tests for new features please (true, existing test coverage is poor, at least we can stop adding new code without tests😃).
AdjusterTestwhich should have a test for each adjuster. Of course you don't have to add the existing ones - just create one for the new adjuster.SymfonyMailerKernelTest::emailInterfaceTest()which should call each function onEmailInterface/BaseEmailInterface. Again just need to call the new function and try to find a way to test that it worked.4) Security implications. This adjuster could potentially be used to bypass access checking. The file might be in the private file system, or it could even be
settings.php. Perhaps security should be a separate issue - in which case this one might need to be postponed on the other.Comment #27
metallized commentedFirst of all, i want to apologize for sending so many commits (i couldn't get phpunit to work on my local, but finallly i did it), i did answer to your comments on gitlab.
Don't get me wrong but why are you concerned about this, symfony do this on
Email::prepareParts()on line 502, anyway i did a extra validation onBaseEmailTrait::embedFromPath()what do you think about this?See my previous answer.
What do you think about what i did?
I add a validation to make sure is a image file, what do you think about this?
Comment #28
adamps commentedGreat many thanks
1)
I am concerned about the case that the email contains the the same URI in two places. If we call embedFromPath() twice then it will create 2 identical entries in
$this->attachments(), see line 391 in Email.php.3) Tests look good thanks.
4) Eventually the security should be similar to the existing Drupal access system - there would be hooks that return an access result, and an adjuster to configure policy in the GUI. In the beginning we can start with a simple rule that is safe. I made a suggestion in the MR based on testing the URL protocol.
Comment #29
metallized commentedI made all your suggestions, please review.
Comment #30
adamps commentedGreat thanks. I made one comment on the new function.
Comment #31
metallized commentedPlease see my answer on gitlab, i think we should stay close to the swiftmailer implementation, we can even remove the absolutepath transformation and scheme/protocol validations.
Comment #32
adamps commentedGreat I like the mime type validation, and being similar to swiftmailer.
In
BaseEmailTrait::embedFromPath(), attachNoPath(), attachFromPath()there is a$mimeTypeparameter. This is passed through to the Symfony Mailer library methods which will guess a mime type if one hasn't been set. I propose that we should guess consistently and only do it once (rather than doing some guessing in the module, some in the library, and sometimes guessing the same file twice).It seems better to guess in the module, using the same Drupal code used throughout the website, so your new code is good. We can guess the mime type in all three
BaseEmailTraitmethods (except if a value is passed already), and pass the answer to the library. We can use the same mime type forallowEmbed().I agree - we already have
AbsoluteUrlEmailAdjuster. If necessary we can make the new adjuster run afterAbsoluteUrlEmailAdjuster.I don't think so. If an email contains a link to a private image, this is safe - the recipient can only see the image if they log in as a user with the correct permissions. If an email contains an embedded private image, then the recipient can immediately see the image without any access checking. The
InlineImagesEmailAdjustertries to embed all images, so this creates a dangerous combination.We should have tests to verify the access checking. Embedding should fail for a private:\\ image, settings.php, or an image denied by htaccess. Embedding should succeed for a public:\\ image, or
core/themes/stark/screenshot.png.Comment #33
metallized commentedWhat you think about what i did?
And what if a site admin/module wants/needs to embed those files?, i think we should not restrict this, in the end is the admin/modules who decides what to embed.
Comment #34
adamps commentedBut it is not only admins that can choose the content of an email. E.g. with simplenews, anyone with permission to edit a newsletter node can insert links to private images - even ones that they don't have permission to see. Maybe with contact module even anon users can insert links in emails.
This module must not break access control of images, that would be a security issue and I cannot commit it. If we restrict too much, then true it is limiting, but I can commit it. We can add ways to manage the access in detail in another issue. Still the default must be secure.
Comment #35
adamps commentedMany thanks, the mime type code is good, I made some detailed comments.
I'm sorry but the security issue cannot be ignored. We have a stable release and the security team would insist on a fix or shut the module down. If the recipient of the email would not be allowed to see the image using a link then also they must not be allowed to see it using embed. We should have tests to verify this. Otherwise, an anonymous user who can guess the URL of a private image could email it to themself (e.g. using webform).
Certainly I don't insist on a solution using scheme/protocol validations - it could also be a different solution, which could even be better. Ideally we would email a private image to the email address of a user who has permission to see it. And we would email an image within /modules or /themes except if it was blocked by .htaccess. That would mean that we fetch the image in a way that runs through the normal Drupal access checking, using the permissions of the current user.
Comment #36
metallized commentedHi, i make all the fixes (i think), about the security issues i will wait to suggestions about how to do it, i did try with the Drupal path validator service, but doesn't works as need it.
We need to make some research about how other modules do it. Note that in
SwiftMailer, they does not do any security validation about image paths.If you want to mark this as posponed its OK.
Comment #37
dennis_meuwissen commentedEmbedding images works well with the new email adjuster, except that the adjuster plugin runs before the email is wrapped. Any images from the wrapped HTML (from email-wrap.html.twig) will then not be embedded. Setting the plugin weight to 850 so that it runs after the mailer_wrap_and_convert plugin solves that.
Comment #38
adamps commentedThat seems to make sense. It also makes this plug-in after AbsoluteUrlEmailAdjuster, which I think should be fine.
Comment #39
heddnEven if this is only scoped to an API addition for mail plugins to call
embedFromPath, this is a great improvement. I vote to get this merged. It works. If the UI components are still being debated, can we move those into a follow-up so we can get the basics of this feature added to the code base?Comment #40
adamps commented@heddn
This issue is for the UI components as indicated in the title. It's not ready to commit due to legitimate security concerns that are clearly explained in earlier comments.
However I agree it's a good idea to split the API change into a separate issue. It can take parts of this patch and likely could be committed.
Comment #41
heddnI've rebased the MR here and moved the API additions over into #3382624: embed attachments API addition.
Comment #42
adamps commentedComment #43
connbi commentedI referred to the code in the PR and created a patch to scan the pictures in the email and replace them with cid. It works normally for me. According to the code in the PR above, there may be a more elegant way to solve it. I don't have time to continue researching this issue at the moment.
my symfony mailer version is 1.2.2. This patch is only used to solve the problem that the embed image is broken in the email body.
Comment #44
ipa 🍺 commentedThanks for the patch. The patch is working except if I add an image to the body with a token then the image is not being processed because the token replace is being called later in the process. Any ideas in how to solve this?
Comment #45
colin.eininger commentedThe weight of InlineImagesEmailAdjuster should be greater than the WrapAndConvertEmailAdjuster one. This way the inline images adjuster can be used in wrap templates too.
Maybe set it to 1000.
Comment #46
ipa 🍺 commentedThanks! I was using the latest patch in this issue, when I switch to the MR it works
Comment #47
metallized commentedHey @AdamPS, do you have any ideas about resolving the security issue when embedding files?, what about a whitelist/blacklist files, also i think that developers can skip this restrictions maybe an
embedFromPathanduncheckedEmbedFromPath?Anything else i can help you?
Comment #48
adamps commented@metallized Thanks please see #3382624: embed attachments API addition
Comment #49
adamps commentedThe API changes are now checked in so work can continue here.
Comment #50
metallized commented@adamps, Is there anything left here?
Comment #51
adamps commented@metallized Yes we still need the adjuster and the test for it. The needs updating a little as the API is different. This issue no longer needs the calls to
setAttribute()orsetHtmlBody()because that's handled automatically in the Email class.Comment #53
adamps commentedThanks. I believe the fix shouldn't need to change Email.php or BaseEmailTrait.php now.
Comment #55
sorlov commentedMade rebase and also fixed missing config schema for EmailAdjuster plugin
Comment #56
adamps commentedThanks. The MR still seems mixed up - please see #53. This MR should only contain the new adjuster, schema changes and tests.
New fixed need to go into 2.x please. We can backport to 1.x after.
Comment #60
adamps commentedComment #62
adamps commentedComment #63
adamps commentedAdding credit
Comment #65
adamps commentedFrom #45
That's a good point. However in v2.x the weight has to be < 600 to run before AttachmentAccessEmailProcessor. I've raised a follow up #3527701: Allow inline of images in the wrapped template..
Comment #66
adamps commentedThanks everyone
Comment #67
adamps commented