Problem/Motivation
We've spent a long time going back and forth in #2492171: Provide options to sanitize filenames (transliterate, lowercase, replace whitespace, etc) because it is a hard issue. This issue will extract the event firing part of that issue into its own issue and add an initial use case.
Proposed resolution
On all files uploaded through the API and UI (i.e. not aggregated JS/CSS files, etc), dispatch a \Drupal\Core\File\Event\FileUploadSanitizeNameEvent to easily let core and contrib change the filename before upload.
Note we must make sure that any implementation allows for the transliteration use-case out-lined in #2492171: Provide options to sanitize filenames (transliterate, lowercase, replace whitespace, etc).
Remaining tasks
Fix #3032376: Files renamed by \_file_save_upload_single() do not have the correct filename on the File entity first.- Finish cleaning up this patch.
- More reviews.
- RTBC
- Commit.
User interface changes
None.
API changes
New \Drupal\Core\File\Event\FileUploadSanitizeNameEvent event is dispatched when a file is uploaded (both via the UI and via the FileUploadResource for REST). Allows listeners to modify the filename (and in some cases, the extension) that will be used for the uploaded file.
\Drupal\file\Plugin\rest\FileUploadResource::__construct() takes a new argument for an additional service dependency:
/**
...
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher
* The event dispatcher service.
*/
This is optional for now, but not defining it is deprecated and any classes that extend FileUploadResource or code that calls the constructor directly should be updated to use proper dependency injection before 10.0.0.
Data model changes
None.
Release notes snippet
@todo (maybe not needed?)
| Comment | File | Size | Author |
|---|---|---|---|
| #89 | 3032390-9.2.x-89.patch | 55.18 KB | alexpott |
| #89 | 87-89-interdiff.txt | 1.18 KB | alexpott |
| #87 | 3032390-9.2.x-87.patch | 55.02 KB | alexpott |
| #87 | 84-87-interdiff.txt | 2.46 KB | alexpott |
| #84 | 3032390-9.2.x-84.patch | 54.76 KB | alexpott |
Issue fork drupal-3032390
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
alexpottHere's an initial implementation based on #2492171: Provide options to sanitize filenames (transliterate, lowercase, replace whitespace, etc)
It moves the security renames into the event - this is beneficial because now all of REST / JsonAPI and the UI will have the same security code making life much easier. Also I think reviewing the security focused code is much simpler now because you have much less to concentrate on.
Also with respect to language - I think we can inject the language manager into the the subscriber that does the transliteration so it's not needed.
The patch here contains #3032376: Files renamed by \_file_save_upload_single() do not have the correct filename on the File entity which is a blocker.
Comment #3
alexpottComment #4
alexpottMoved a load of stuff around so we can deprecate file_munge_filename() and so the file module is not required for the event.
No interdiff because the amount of change.
Comment #6
wim leersDoes this means we need to add explicit test coverage to
\Drupal\Tests\file\Functional\FileUploadJsonBasicAuthTestfor this?Or are we at a point where we don't want to add explicit REST test coverage because we're making that convergence happen?
Comment #7
alexpott@Wim Leers ah good point I need to break
out into a separate bug fix.
Comment #8
alexpott@Wim Leers opened #3032620: \Drupal\file\Plugin\rest\resource\FileUploadResource uses basename() when it needs to use the Drupal version and removed the fix from here. The fix is not a blocker.
Comment #9
dwwNice to see lots of my code from #2492171: Provide options to sanitize filenames (transliterate, lowercase, replace whitespace, etc) has survived, and thanks for all the improvements! Found lots of things, mostly nits, a few are (slightly) more substantial.
Shouldn't this say
\Drupal\Core\File\Event\FileUploadSanitizeNameEventinstead?Hah! Makes you wonder why this function exists. ;)
Nit:
::setExtension()(with parens)Why are we doing this? I thought you were supposed to have the name constant live with the event. This seems weird.
This wording is now clumsy. "can only be modified before if ..." - huh? Also s/then/than/
Maybe: "The file extension can be modified via self::setExtension(), but only before self::lockExtension() is called. Core locks the extension (for security reasons) with an event subscriber using priority 1024, so if you must alter the extension, your subscriber must have a higher priority than that."
Or something. This whole thing seems a bit awkward and clumsy. I don't love the new exception being thrown and all the additional complication for this. *shrug*
We no longer use FileSystemInterface::basename() in here. Can probably remove this @see.
Not really "read-only" anymore. Stale comment from my previous version of this event.
I'm kinda sad that the new version of this event now cares about this.
And now that I've made it to the end of this event, I don't see it actually being used anywhere, other than to update it in makeTextFile(). What gives? ;) Why is this here?
maybe $isSecurityRename to self-document it's a bool?
$extensionLocked ?
"... file upload sanitize name event object."
Maybe: "The filename (without extension) to use..." ?
foo.php.txt is okay? don't we need to make it foo._php.txt or something? Not totally clear why this function is here, frankly.
Does this want to take a bool and set the flag to whatever we tell it? Everything else in here called "set" lets you set what you need. Seems a bit inconsistent that this particular one forces the flag to TRUE.
Weird. :/ This seems fairly convoluted. I don't have a better proposal just yet, but spidey-sense says yuck. ;)
can we pass a message to pageTextContains()? Would love to keep the more helpful message from the assertRaw() we're removing.
// running FileEventSubscriber::security() on the event that the extension is locked.
s/testSanitizeFilename/testSecurity/
s/ane/are/
s/sanitization options/expected extensions (as a space-delimited string)/
Doesn't look like Unicode to me. Should this move up to the ASCII section above and get a better name?
Comment #10
dwwThis fixes the trivial / obvious nits:
1-3
6,7
9-12 (for 10, I also renamed the exception class to
ExtensionLockedExceptionto match).17-21
@TODO from #9:
4-5
8
13-16
Still NW, but a bit closer. ;)
Cheers,
-Derek
p.s. Fixed the API changes section of the summary and some other updates/fixes. Also, the summary mentions language, but that's not addressed anywhere in the patch.
p.p.s. Queued for testing, even though it's NW, to make sure I didn't accidentally break something. ;)
Comment #11
alexpottRe #9 - firstly thanks for the review and fixing the nits.
Here's some thoughts / work on the outstanding points.
4. A final class is how we do this - see all the *Events classes in core - ConfigEvents / MigrateEvents etc
5. I agree. Thinking about this some more I realised that we did the whole can't change the extension thing because the security stuff was happening first but instead we should ensure it comes last. We can't get do that with Symfony's events but we can use a number like -1024 and document it.
8. Well if we rename a file to .txt for security reasons we seem to have be able to make sure the file is uploadable. Imo this functionality is a bit funny. But we either have to maintain it or open an issue to change it first.
13. This is all existing behaviour and yes the fact this is not munge the same as if the it was pass via file_munge_filename() - but this is all existing behaviour.
14. Imo this is fine. This flag can only be turned on. We deprecated the bool param on \Drupal\Core\Entity\EntityPublishedInterface::setPublished for example. We're setting the event to be a security rename.
15. Again this is all existing behaviour - yes it is weird and not what many people actually experience when uploading via the browser which will limit extensions automatcially.
16. No and for me
$this->assertSession()->pageTextContains('File name is php-2.php.');is better than$this->assertRaw(t('File name is @filename', ['@filename' => $this->phpfile->filename]), 'Dangerous file was not renamed when insecure uploads is TRUE.');because the filename is hidden and convoluted and subject to false positives because there is no fullstop after @filename.Patch addresses .5
Comment #13
dwwThanks for the new patch and the replies.
#11.4: cool, duly noted.
11.5: cool. ;)
11.8: Okay, fair enough.
11.13: I also notice the existing functionality (removed from
_file_save_upload_single()) does this:$file->setMimeType('text/plain');. Not totally clear how that happens in the new flow. I guess we assume this gets it right:+ $file->setMimeType(\Drupal::service('file.mime_type.guesser')->guess($file->destination));11.14: Ok.
11.15: Ok, I can live with it for now.
11.16: Fair enough. Yes, I agree the filename in the message is good. I just meant it'd be nice for the assert message to say the functionality that fails, if possible. But if not, I'm okay with this as-is. Totally agree on the fullstop and stopping false positives, good catch!
Review of patch #11:
Possible can of worms: Now that we're letting any event subscriber munge the extension, I wonder if all this complication is needed anymore. Originally, we split extension from filename to ensure that subscribers couldn't touch the extension. Now, subscribers munge the extension by design. Maybe we should just have (get|set)Filename() and it operates on the full name, and if you need to munge it, you do whatever you want.
You removed the place in
FileEventSubscriber::security()that calls this, and the test coverage, but the function is still here. Seems we should remove it entirely now.stale @see
Another stale @see
I asked in Slack about this, but for the record... it seemed weird to me that this event listener lives in system. @alexpott replied:
Still slightly weird, but okay. I guess in theory you could have rest.module enabled without file.module (?), and you'd still definitely need this subscriber to fire. Perhaps we should add a comment about this to the PHPdoc for this class?
Yeah, see, we'd only have to do a single
str_replace()here if we had a single filename instead of name vs. extension.Nit: allowed.
Thanks again!
-Derek
Comment #14
alexpottRe #13.1 I think you're right. I'll make lowercasing work properly too :) - we missed that on the original issue.
Also addressed all the other points from #13.
This is getting much much nicer. Thanks for the reviews @dww. Again I think splitting this issue off and getting the security stuff into the event is proving its worth.
So I think the big issue is whether we want to address the odd auto-allow of .txt files in a separate issue that blocks this one. On one hand because it affects the API I think it is worth it - on the other - this behaviour has been around for a long long time - see #10560: Upload: skip admin checks, rename unsafe types. Given that what we are doing here is a task and I would argue the current behaviour is a bug I'm going to open issue at least to explore the possibilities.
Comment #15
alexpottComment #16
alexpottYay someone has realised that buginess and explained it already - #2863652: Do not allow files with "php|pl|py|cgi|asp|js" extensions to be renamed to *.txt and be uploaded if *.txt is not allowed by the field widget
Comment #17
panchoSo true. The other issue was all to convoluted, so this way we'll get it all done one after the other. :)
I also want to say sorry to @alexpott that in the other issue I overreacted quite a bit. Think I was busy and invested more time with that issue than at that moment I could afford, but that's my problem not yours. Peace?
Yep. I think #2863652: Do not allow files with "php|pl|py|cgi|asp|js" extensions to be renamed to *.txt and be uploaded if *.txt is not allowed by the field widget needs to be fixed first.
Comment #18
dwwRe: #10.ps I see you mentioned that in #2. I just did some digging into file.module. I can't see anything that's doing anything fancy with a language provided with the upload. REST doesn't do that, either. It seems in all cases, we end up just getting the default language. So yeah, I don't think the transliterate case cares, and we don't have to mess with that in this event. Removed that from the summary.
Meanwhile, re: #14: Yay. :) Agreed.
Re #15: almost there. ;) Sorry I missed some of these earlier. Some are new because of recent changes.
Now that this is in a generic
FileEventsclass, probablySANITIZEisn't enough. Perhaps at leastSANITIZE_NAMEor something would be better.Good question. What do you propose? It seems we don't want this event also responsible for changing destinations, so yeah, probably a good idea to make sure there's no path info in here. We could potentially strip it for them, or perhaps throw an Exception if a full path is passed in?
s/(without extension)//
same
Yeah, at this call site, it's not totally self-evident what's being sanitized. The class name a few lines above is good, but if I missed that, I might get the wrong idea here...
we already have this as
$original_filename-- shall we reuse that instead of callinggetClientOriginalName()again?s/FileUploadEvent (sanitization)/FileUploadSanitizeNameEvent/
Presumably we need to link this to https://www.drupal.org/node/3032541 now, not 2972665.
I'm not sure I love "security" as a function name here. ;) Don't we at least want a verb?
enforceFilenameSecuritySettings()or something?s/and admin/and admins./
Do we want a follow-up for this?
For completeness of this specific unit test, shouldn't we also try to create a 2nd event with some allowed extensions and see what happens?
Hrm, now I'm a bit confused how this is passing at all. This claims it provides data for
testEvent(), but that function no longer exists. It's marked as the provider fortestEventFilenameFunctions(), but the function signature of that doesn't match what this provider is returning. WTF? ;) I think this provider needs help.@Pancho re: #17: Yay! Nice to have you back. :)
Comment #19
alexpottThanks for the review @dww.
Comment #20
pancho1.
@see ?
2.
+ // @see \Drupal\system\EventSubscriber\SecurityFileUploadEventSubscriber::security()2x "sanitizeName()" ?
3.
a.)
Just a test, but how about having the three input parameters first and the two test parameters last?
b.)
Furthermore a bit more verbose and consistent:
c.)
And for a flag, this is quite confusing. Propose renaming, too:
$expected_security_rename => $expect_security_renamed.)
public function testSecurity(...)testSanitizeFilename(...) ?
4.
Question: you are referring to this being renamed to 'php_.php.txt', so this is not about #2863652: Do not allow files with "php|pl|py|cgi|asp|js" extensions to be renamed to *.txt and be uploaded if *.txt is not allowed by the field widget, right?
Comment #21
berdir1. No, @see must only be used as a standalone reference to other things at the bottom of a docblock, inline is "See" correct.
Comment #22
dwwRe: #20:
2. Good catch.
3. Agreed on all 4 subpoints.
4. Right. See #9.13: I found it weird that the final "rename *.php to *.php.txt" doesn't use the same logic as file_munge_filename() does. Seems like if we rename "foo.php.bar" to "foo.php_.bar" (from filename munging) that if we're renaming "foo.php" to be "more secure" we should use the same logic and rename it to "foo.php_.txt", not just "foo.php.txt". This is a separate bug from the #2863652: Do not allow files with "php|pl|py|cgi|asp|js" extensions to be renamed to *.txt and be uploaded if *.txt is not allowed by the field widget weirdness. AFAIK, there's no open issue about this particular inconsistency.
Comment #23
alexpott@Pancho, @Berdir, @dww thanks for all the reviews.
Re #20.4 I determined that whilst this feels inconsistent it does not pose a security issue because Apache will recognise the .txt extension and not fallback to the php extension. I've changed the @todo to a comment to reflect this but I've not opened an because it's been this way for a very long time and I'm not sure it matters.
Addressed #20.2 and .3.
Comment #24
pancho1.) Further nitpicks in random order:
a.)
+ * @deprecated in Drupal 8.7.0 and will be removed before Drupal 9.0.0. Dispatch81 chars.
b.)
81 chars + "i.e."
c.)
=> "Split up the filename", also comma missing after basename.
d.)
Should match the new parameter order:
e.)
May be slightly shortened:
2.)
Another considerably bigger aspect, even if possibly out of scope here:
+ const INSECURE_EXTENSION_REGEX = '/\.(phar|php|pl|py|cgi|asp|js)(\.|$)/i';This is nice, however what about all other extensions considered potentially sensitive by our default .htaccess, which are therefore just as well disallowed in a default install:
Tested this with a *.module file which didn't end up being security renamed, but not visible either.
3.)
If adding underscores is necessary (still not sure why), then matching possible extensions with 2 to 5 characters might not be enough. Some are longer. Propose we figure out (and document) why this is necessary, what exactly is to be avoided, and then figure out which possible extensions we want to match.
Comment #25
panchoComment #26
dwwRe: #24.1: Sure, all seems fine.
#24.2: A. Out of scope. ;) B. I disagree. The .htaccess stuff is preventing those files from being seen. This dangerous extension renaming stuff is to prevent code execution. foo.module isn't executable by apache, so it doesn't need to be renamed to foo.module_. Why our .htaccess is preventing all *.module files (to stick with this example) from being seen, not just the ones provided by core, is a separate issue.
#24.3:
git show 5524445b;) @see SA-2006-006.Sadly, that was back in the days of security-by-obscurity in the commit message, so Gerhard (@killes) just said "fixing stuff" (on the dubious assumption that hackers would be less interested in the commit and less quick to write exploits). There are some code comments, but not too much help. But in short, if you upload foo.php.bar and Apache doesn't know what to do with ".bar" files, it will fall back and treat that as a .php file, and happily execute it for you. I just tried this again. Much to my disbelief, this Apache behavior hasn't changed in 13+ years and it's still executing the PHP code, not just displaying it. :(
TL;DR: We do not want to change this behavior in any way. This issue is simply moving around the code. If you change how it works, you risk re-introducing an arbitrary code execution vulnerability.
Thanks,
-Derek
Comment #27
panchoThis means 2. is out of scope, while 3. works as designed. So we're just fixing the few nitpicks and might be done? Nice.
Fixed #24/1 (a-e).
Also tried to document the Apache issue in a few words, so it gets a bit clearer why we implemented that and how to find a tiny bit of further information about it.
Certainly you'll find a better wording, possibly a better reference.
Totally agree now. In this case, we might want to move these monolithic lines of code to a separate internal function, so it is a bit separated from the rest of sanitizeName().
Comment #28
panchoComment #29
alexpottThis change is incorrect. We have 80 characters before we should line break.
Let's use the full 80 characters.
\Drupal\Core\File\Event\FileUploadSanitizeNameEvent::addAllowedExtension()which feels good because changing the extension to something that's not permitted feels like something we shouldn't be doing (hence that issue).Comment #30
alexpottComment #31
dwwI'm running out of things to complain about in here. ;) But I found one:
#9.1 again: s/
FileEvents::SANITIZE/FileUploadSanitizeNameEvent/But, makes me wonder: what if I just want to "munge" a filename, but not necessarily be subjected to lowercasing, transliteration, whitespace replacement, and any other thing contrib might dream up that could happen if I dispatch this event? Am I a sad panda? Perhaps this isn't a fair deprecation. Perhaps the replacement for
file_munge_filename()should continue to live as a stand-alone function somewhere (along the lines of @Pancho's concern at the end of #27)? If so, perhaps theFileSystemservice makes more sense than a public static method of system's event listener class?Meanwhile, if this is truly blocked on those other two issues, then let's call this postponed...
Thanks,
-Derek
Comment #32
pancho1.)
You shouldn't be... :)
Yep, that's even better.
2.)
Do I get it wrong or is both removing Null bytes and munging the filename only necessary if we're not adding a .txt extension, i.e. if after all our sanitizing it isn't a SecurityRename? Why are we doing both in a row? And wouldn't we want to remove Null bytes and munge even if 'allow_insecure_uploads' is TRUE, so while we're helping the panda get his otherwise insecure uploads we'd still be avoiding totally unexpected behaviour?
3.)
I found that the PHP Null byte bug has been fixed for file_exists() in PHP 5.3, see http://php.net/manual/en/security.filesystem.nullbytes.php and https://bugs.php.net/bug.php?id=39863. While it remains for include/require, this shouldn't be relevant here. In that case we might be able to get rid of it altogether in another followup.
Don't think we should change any of this in this issue, we should keep it in mind when reorganizing the actual code to the FileSystem service.
=>
So for now, prepareFilename() should probably basically keep doing what _file_save_upload_single did, except it also dispatches the new event first:
\Drupal\Core\File\FileSystem::mungeFilename()
\Drupal\Core\File\FileSystem::ensureNotExecutable()
So everything is in place and in a followup we can rethink the logic. Also, if the event subscribers already alter the filename in a way that ensures nothing needs to be munged or ensuredNotExecutable, then we're done. And the other way around, if the event subscribers should possibly do something that makes removing Null Bytes, munging or ensuringNotExecutable necessary, while insecure uploads are still not wished, then it's not to late to do so.
Comment #33
alexpott@Pancho I disagree with moving the security related filename changes out of the event. The event intentionally already has a weight of of -1024 to come last. The current event dispatcher has a limitation in the fact that we can't enforce lastness so atm we use convention but at some point in the future we will be able to specify that.
@dww I don't think the use-case for doing file name munging on your own really exists. And in fact there are more than a few places in contrib that do it where they should be doing more. This is yet another intended benefit of centralising this to a single event. Having a single place where use uploaded filenames is a path towards a more secure and consistent Drupal.
Yes once we've done this we can carefully pick over the security event and see what's necessary and what has holes. BUT I would caution that some of this exists to provide multiple lines of defence against incorrectly configured servers and the like. So for example pointing to a .htaccess and sayign this is not necessary anymore does not feel correct.
Also we nede to be careful not to do more than necessary. For example the null byte removal doesn't just make sense on security grounds it also makes sense on a usability perspective. Why should filenames be allowed that contain such characters - there is no reason to support that. I also question whether core should support uploading .php files and whether supporting that should be a contrib module BUT on the other hand I can see the use of saying upload any file. So we need to cope with php files anyway. That said we could consider adding a recommendation that you limit to a set of specific extensions because it represents a better experience for your users (their browser will help them choose the right sort of file)
Comment #34
panchoRe: moving the security related filename changes out of the event; doing file name munging on your own
Mhm, well, nevermind.
Just to make sure I'm not strawmanned: Nowhere did I say the additional line of defence wasn't necessary anymore. I just pointed to the inconsistency and the fact that we're allowing files being uploaded that in the end aren't allowed to be displayed by our default .htaccess directive.
However, we agreed that this is way out of scope and might be a followup.
Out of scope here. Let's discuss in a followup.
Comment #35
alexpottOne to go... #2863652: Do not allow files with "php|pl|py|cgi|asp|js" extensions to be renamed to *.txt and be uploaded if *.txt is not allowed by the field widget
Comment #36
wim leers#7 + #8: awesome, thanks a lot for #3032620: \Drupal\file\Plugin\rest\resource\FileUploadResource uses basename() when it needs to use the Drupal version!
#9.5 + #11.5: yep, this is Symfony's event system not being designed for pluggable/extensible systems like Drupal. Explicit
beforeandafterrelationships to denote ordering instead of randomly picked numbers would solve this. But alas, we can't solve that here.#16: kudos for creating #2863652: Do not allow files with "php|pl|py|cgi|asp|js" extensions to be renamed to *.txt and be uploaded if *.txt is not allowed by the field widget too 👏
#17: ❤️
🐛 s/SANITIZE/SANITIZE_NAME/
Nit:
The name of the file being uploaded., because you can't upload a filename :)🤔 What's the plan for this? Do we really want to tackle this in this issue?
👍 This is removed in both code paths (REST and non-REST) in favor of the event. Great! Convergence!
🤔 Wouldn't it be better to use the setters on
FileInterfaceinstead?Nit: still needs meaningful description.
This too.
Nit: why not
->setFileName($filename . '.txt')?Would be good to have non-auto-numerical array keys here that provide useful descriptions. Makes it much easier for future maintainers (and current reviewers, like me) to understand what exactly these permutations are doing.
Same as the first remark.
👍 Great test case labels here :)
Comment #37
dwwAgree with almost all of #36.
However, for #36.8:
Because the whole rest of this function is operating on and building up
$filename. Arguably, this part:should/could be simplified to:
No need to call
setFilename()andsetSecurityRename()twice. Might as well put thesetFilename()at the very end of the function, to cover all possible code paths.Comment #38
dwwp.s. re: #36.1 and .10: I prefer #9.1 and #31. Don't reference the
FileEvents::SANITIZE_NAMEconstant (which isn't an event you can dispatch, right?). Reference the class of the event you need to use:\Drupal\Core\File\Event\FileUploadSanitizeNameEvent.p.p.s. That makes me wonder, I don't see
SANITIZE_NAMEanywhere inFileUploadSanitizeNameEvent.php. Don't we want that at least in the class's docblock comment?FileEventsdoes an@seeto point toFileUploadSanitizeNameEvent. Seems we want to have a pointer in the other direction, too.Comment #39
dwwCopying over some tags from #3021652: Deprecate upload-related functions file.inc and move to a file upload service which has been superseded by this issue.
Comment #41
andypostI guess as 2863652 no longer public - it makes sense to proceed
Comment #42
alexpott@andypost we need to fix what #2863652: Do not allow files with "php|pl|py|cgi|asp|js" extensions to be renamed to *.txt and be uploaded if *.txt is not allowed by the field widget was. I've asked for the issue to be made public again and I've done extensive other work but have not had progress yet.
Comment #43
kim.pepper@alexpott Is there an updated patch that could be posted here for others to help with?
Comment #44
elijah lynnComment #49
alexpottRerolled the patch and take #36 into account.
Plus in light of a recent core security release put more effort into unit testing of all permutations.
One big change this version of the patch makes is that files are munged regardless of the
allow_insecure_uploadssetting. That setting is now only responsible for renaming a file to .txt if you've allowed the .php extension in your file field and you haveallow_insecure_uploadsset to FALSE. I think this is in the spirit of making the code predictable and easy to reason about.No interdiff because there were massive conflicts after all this time and I guess we're all kinda starting again.
Comment #50
chr.fritschShould be \Drupal\Core\File\Event\FileEvents::SANITIZE_NAME, I guess
CR needs to be updated
Sounds like the 'to' shouldn't be there
Comment #51
larowlanLooks much nicer, just a couple of nits and one question
yep, I think we should throw an invalid argument exception
nit: some consistency issues here w.r.t whitespace
I guess the todo regarding ensuring this is a basename applies here too
now that we have a value object, should we work with an array of extensions internally?
is this 'to' needed? if so, should it be 'too'
this needs to be updated now
should there be a trigger error here, or is it omitted because internal
why is this changing now?
Comment #52
kim.pepperI had a go at writing the change record.
Comment #53
alexpottFixed everything apart from #4. Yeah I ummed and ahhed on that. I left it as a string so that we can use the same validation as file_validate_extensions().
I think it is worth considering how can we make this event and the file_validate work better together.
Comment #54
larowlanIf that's the next step, should we work towards the API we would want in the future rather than what we have now?
Comment #56
alexpott@larowlan I agree it needs to be planned for on this issue.
The issue is that there is no point sanitising the filename if the extension it has will cause it to be rejected. Prior to the recent security release we used to do this and this caused bugs. I think the question might be whether we want to have a single event that handles both sanitisation and validation. Or whether that'll make our life tricker.
Let's consider it from the validations that always run on core code (file_validate_extensions, file_validate_name_length) and the security renaming we do. We need to reject based on extension before we any work on the filename. We need to check the name length once any renaming has occurred. This check also needs to happen after \Drupal\Core\File\FileSystem::getDestinationFilename() has been called to ensure that the check is made after any renaming due to adding _NUMBER to prevent clashing filenames. I'm not sure what's the best way to satisfy all those requirements yet.
What we have with this patch is:
1. An event that changes the filename according to sanitisation rules
2. The getDestinationFilename() rename
3. File validation via the hook system
At least that is logical. I.e. get all the renames out of the way and then do the validation - because some of the validation takes into account the name.
I guess one improvement we could make is to add another event listener that runs really early in the sanitisation event and bails if the extension is not valid. But that then makes following the logic in \Drupal\system\EventSubscriber\SecurityFileUploadRename::sanitizeName() tricker because you have to know that this event works in tandem with this new SecurityFileUploadExtensionCheck we'd be adding. Maybe what we have here is okay? It's certainly better.
Comment #57
larowlanYeah 100% this is a huge improvement. I think it's early enough in 9.2 cycle that if we have to refine this as we get to the next steps then there's time to do so
Comment #58
alexpott@larowlan - I'm v v happy to explore all ideas on this issue. Is there anything you have in mind. I started exploring adding The extension validate bit as a separate event subscriber but it made me uncomfortable.
I think a big decision that would make things much much simpler would be not allow the addition of .txt unless txt is already a valid extension. That would mean that the sanitisation event wouldn't need to alter the list of extensions. The ability of to do this feels really wrong. I guess we could break that out into a separate issue. That could block this one. As it has a material impact of the final API.
Comment #59
alexpottOf course we have that issue already! #2863652: Do not allow files with "php|pl|py|cgi|asp|js" extensions to be renamed to *.txt and be uploaded if *.txt is not allowed by the field widget
Comment #60
alexpottIn order to limit the API even further - ie. to remove addExtension() we need to land #2863652: Do not allow files with "php|pl|py|cgi|asp|js" extensions to be renamed to *.txt and be uploaded if *.txt is not allowed by the field widget first. Therefore postponing this issue on that one.
Comment #61
larowlanThe blocker is in
Comment #62
dwwFantastic, thanks! Updating title, tags and status to match. Pretty sure NW is correct, to take advantage of #2863652 now being in...
Cheers,
-Derek
Comment #63
alexpottRerolled on top #2863652: Do not allow files with "php|pl|py|cgi|asp|js" extensions to be renamed to *.txt and be uploaded if *.txt is not allowed by the field widget and removed the now unnecessary API. The interdiff is a pseudo interdiff because of conflicts.
Comment #64
alexpottFixing #51.4 and doing more tidy up.
Comment #65
kim.pepperLooks great!
Do we need to add this to the docblock?
Do we need to trigger a deprecation here?
Comment #66
alexpottThanks for the review @kim.pepper - re #65
I've added a section on file upload security to file.api.php. I think this overview is missing and feels worthwhile adding here.
Comment #67
dwwThis is looking fantastic, thanks!
Should we expand the CR to talk about how to replace file_unmunge_filename() with str_replace(), too?
Seems like @see _file... would be the preferred version (based on grepping the rest of core). Probably these should be the same.
Wants a comma after empty.
"... to TRUE." ?
This section / summary is fantastic, thanks for adding it!
Should we expand the CR to mention this change, too?
Why have $event_dispatcher as a local var instead of just
\Drupal::service('event_dispatcher')->dispatch(...);?"...building the file..."
There's no file_destination(). The original "If the destination is FALSE..." seems better for this.
Out of scope, but I wonder if adding this status message is wise via this API. We're maintaining previous behavior, so we should keep it, but I wonder if we want a follow-up to reconsider this.
Sooooo much cleaner now! 🎉 Yay.
Are these stray comments from another issue?
Note to self and other reviewers: This is the correct behavior now -- each extension before the last is now being munged:
SecurityFileUploadEventSubscriber::sanitizeName() contains:
Given the above, this is needed, since the same original filename is now being munged the same way twice, resulting in a duplicate "image-test.png_.php_.png" filename, which then gets the "_0" appended to avoid collisions. The automatic .txt rename is no longer happening.
Comment no longer matches the code.
Still wish there was a better way to ensure this always runs, and runs last.
"... without file munging."
I don't think this is true. Copy/paste error?
Comment doesn't match.
No longer true.
Attached patch fixes: 2, 3, 4, 8, 9, 12, 15, 17, 18, 19, 20.
TODO: 1, 6, 7, 16?
Nothing need: 5, 10, 11, 13, 14.
We have a CR, removing that tag. However, I think it needs edits (1 + 6), so tagging for that.
Comment #68
alexpott1. I don't think so.
file_unmunge_filename()really has no use-cases. It's never used by core - nor any Drupal 8/9 contrib.3. I disagree - reverted in patch
4. I disagree - this is about flagging the rename that you've done as security related. Reverted in patch
6. This has 1 usage in contrib and that is in a module that has moved to core. That said I think it is worth it for core developers to know about this so will add.
7. Sure makes no difference.
18. Yep it is true. The exception being tested here is thrown from that method.
Comment #69
alexpottI've re-worked the change record to include all deprecations.
Comment #70
berdirDidn't do a detailed review, but the change overall looks great. Only thing I was wondering is if it would be worth having a "better" API than having to fire the event yourself. We still have a wrapper method now in those (tempary) API related things now. Based on a quick scan through the comments, this wasn't discussed yet. The API used to be more complicated, but there is still the security flag argument that needs to be passed back, so I guess a single-method API for this would still require a value object to be returned (could be the event class) so I guess it's not really worth it?
Should the security rename flag be mentioned in the change record and what you're expected to do with it? The API related usages aren't using that atm, but I guess we have no mechanism there to return information/warning messages there anyway and it would be out of scope for sure as it wasn't done before.
I've not followed recent developments too closely, but from what I understand, Symfony has been moving away from named events in many cases in favor of just using the event class name as the identifier per https://symfony.com/blog/new-in-symfony-4-3-simpler-event-dispatching. Has there been any discussion on whether we should do the same, at least for new events?
Comment #71
alexpott@Berdir great point about the new best practice from Symfony. I've implemented it and it looks nice to me.
I agree that adding an API helper is not that useful. I don't think the main case here is people to invoke the event themselves. If you've used #type => 'file' you really should be calling file_save_upload().
Good point about documenting the security rename on the CR. The main purpose is for people to be able to add listeners to change a name and I don't think many would need to set or check the security rename flag. But as https://www.drupal.org/sa-contrib-2020-036 , https://www.drupal.org/sa-contrib-2020-037 and https://www.drupal.org/sa-contrib-2020-035 show - some modules are reimplementing this code themselves so it'd be good to document \Drupal\Core\File\Event\FileUploadSanitizeNameEvent::isSecurityRename() and say that it should be checked if you do invoke the event.
Comment #72
alexpottI've updated the change record.
Comment #73
dwwRe: #68: Okay, fair enough. I disagree on some of your reverts, but it's not worth delaying this to further discuss them. ;)
Re: #71: Cool about not needing the event name, and the class that's just there to hold the event name constant. Yay. Agreed that looks more nice.
The patch is excellent. I'm nearly out of concerns. ;) Just one tiny cosmetic thing for the doc comments:
Do we want the "::class" part in these doc comments? I know we need it for the actual call sites like
$events[FileUploadSanitizeNameEvent::class][] = ['sanitizeName', -1024];, but in a comment, "Dispatch a \Drupal\Core\File\Event\FileUploadSanitizeNameEvent event instead." seems sufficient (and more legible). Attached here in patch / interdiff form.Re: #72: I re-read the CR. I added a missing comma, but otherwise it looks great.
Also, just re-checked the summary. It was mostly accurate, but it mentioned 9.0.0 where it now needs to say 10.0.0 for the constructor deprecation stuff. Fixed.
Thanks, @alexpott, for driving this one this far! Almost home. ;)
Cheers,
-Derek
Comment #74
kim.pepperRe-reviewed the patch, feedback has been addressed, and this all looks good now.
Comment #75
catchSeems weird to use a public constant from an event subscriber here. If INSECURE_EXTENSION_REGEX needs to be public API it should be outside something that is @internal.
Here too.
I can see a call to file_validate_extensions() removed from here, but I don't immediately see where it's added back.
And also here.
Missing a 'by'. And should this by file_validate_extensions()? And per above comments where does that file_validate_extensions() actually happen? I think if we're relying on it being called, we should point out where (for the various code paths in core from where the event can be triggered).
The rename event subscriber is set to 1024 to run last - hopefully that works. However there is also the case with the event dispatcher that a previous listener could call Event::stopPropagation, which will stop it from running altogether. Do we then need to check $event->isPropagationStopped() in the calling code to detect this and throw an exception or warning? This is a disadvantage of moving to an event (or a hook since hook_module_implements_alter() would also let you prevent the logic being called). Or is the intention to let modules swap this out if they really, really want to?
Comment #76
berdirWhat about making the secure-rename flag a tri-state? NULL/TRUE/FALSE, and at least one event subscriber must set it to TRUE or FALSE to be accepted? Then you can skip/replace if you really want to but it must be a deliberate decision? However, then we definitely need an API wrapper around invoking the event so we can verify that.
Comment #77
alexpott@catch thanks for the review.
Re 1,2 Moved constants to FileSystemInterface - nice spot!
Re 3,4 Yes that's a massive advantage of this new approach - this doesn't end up being called twice. We are doing the equivalent check in \Drupal\system\EventSubscriber\SecurityFileUploadEventSubscriber::sanitizeName().. .see
Re 5. Added detail here. Note that the new documentation in core/modules/file/file.api.php also deals with this. This new event is about sanitising a filename. It is not about validation of the filename. Mixing validation and sanitisation is what makes the current code in HEAD extremely complex to follow and liable to be badly copied.
Re event propagation - good point. We can stop people from calling stopPropagation() so let's do that.
Comment #78
catchIsn't this file_validate_extensions()?
Stopping propagation altogether seems good.
Comment #79
alexpottIndeed it is file_validate_extensions() - fixed.
Comment #80
andypost@alexpott there's one more
file_validate_extensiona line bellow the one fixed (could be fixed on commiit)Comment #81
dww@catch: Great points, thanks for the careful review, and apologies we didn't deal with some of those points sooner.
@alexpott: Great work on the fixes. +1000 to moving the constants to FileSystemInterface.
Agreed on blowing up in the face of anyone that tries to call
stopPropagation(). Nice solution that doesn't further complicate this or expand the API surface. Do we "need" to document the @throws, though? It's all the function does. ;) But trying to preemptively cover any final concerns.@andypost: Good catch on that last reference to "file_validate_extension()". Fixed here.
Comment #82
dwwAnd here's a @throws comment, just in case. ;)
Comment #83
chr.fritschWhat about this TODO in
file_munge_filename()?Should it be removed or can it stay because the function is deprecated anyway now?
Comment #84
alexpott@chr.fritsch excellent point. Let's do the todo to ensure that everything is consistent if we add new extensions to the insecure lists.
Also tweaked a comment to be even clearer.
Comment #85
kim.pepperLooking really good. Nice improvements. Back to RTBC
Comment #86
catchCan we add a comment here referring to where the validation happens? Also it's a bit weird that we're passing all validators into here then only use file_validate_extensions (I realise this is pre-existing in the code this replaces too).
Same thing here.
What happens if someone adds an event with priority -1025, do we just assume they know what they're doing?
To me it's a bit odd that we don't munge the filename if validation would already fail. If validation is going to fail, what harm does it do to munge the filename? If validation doesn't run, then why not do the thing that the event claims it does - feels like it would compound an existing issue?
Comment #87
alexpottThanks for the review @catch:
.txtnot rejecting here would allowfile.phpto be uploaded as it will be renamedfile.php_.txt. So this code is good. RE...then you are on your own - and that is not at all affected by this patch. We've tried to cover this off with additional documentation added in core/modules/file/file.api.php. This is tested in \Drupal\Tests\file\Functional\SaveUploadTest::testHandleDangerousFile - see the bit below:
As this is adding comments and changing a small number to a very small number setting back to RTBC.
Comment #88
alexpottCrediting everyone whose comments have had a material effect on the final patch.
Comment #89
alexpottDiscussed #86.4 a bit more with @catch who pointed out that a comment with some of the info from #87 in would be good. So here's the additional comment - which has had input from @catch.
Comment #90
anmolgoyal74 commented@alexpott
This line looks a little odd.
Maybe we can put it like this.
Or you can suggest something better.
Comment #92
catchhmm, it's not a very easy thing to explain, but also I don't think the suggestion in #90 is an improvement - for example the double 'prevents'. I can't think of another way to word this either, so think we should go ahead with the current wording in the patch.
Committed 69a0a83 and pushed to 9.2.x. Thanks!
Comment #94
pavelculacov commentedThank you