Problem/Motivation
We used to remove unused files in D7. With [#9204215] we changed this in D8. Unused files will stay in the system and eventually be removed during cron runs based on a configuration variable. This introduces a potential security issue. Unused temporary files will generally be accessible for download to any user (incl. anonymous). This is not desired.
Proposed resolution
Block download of unused temporary files. Allow contrib modules to take ownership of this files. By taking ownership modules also take responsibility for secure treatment of those.
This closes the gap from Drupal 7 and makes the behavior from a security standpoint essentially the same.
If a module relies on this behavior, it must either:
- add a usage count
or
- make the file permanent
Given the file is no longer immediately deleted and having temporary files with no usage being "permanent" feels very very wrong, we deem that an acceptable compromise in getting Drupal 8 releasable.
Also the file_entity use case is mainly dealing with public files.
User interface changes
- None.
API changes
- No user except the owner has access to a private temporary file with usage count of 0.
Original report by @David_Rothstein
In Drupal, hook_file_download() is used to control access to private files. It operates on the principle that if one module wants to grant access and another module wants to deny access, the module which wants to deny access wins.
The File module implements this hook to control access to file attachments. It denies access to a file whenever all the entities that the file is attached to are not accessible by the current user.
In Drupal 7, a key part of this system is that when a file is no longer being used it is immediately deleted. This means that even after the File module stops managing a private file, it still ensures that users who did not have access to the file before will not suddenly get access to it.
In Drupal 8, this behavior was removed (I think in #1401558: Remove the usage handling logic from file_delete()) which introduces a security issue. The security issue is not present in Drupal 7, but can be reproduced if using the core patch at #1399846-263: Make unused file 'cleanup' configurable since that proposes backporting a similar change to Drupal 7. The instructions below will work for either Drupal 8 or Drupal 7 with the above patch applied; given that a contrib module is required to reproduce this, it is a little easier to understand with the Drupal 7 version.
To reproduce from a fresh installation:
- Configure image fields on articles to be private files.
- Ensure that a module is installed which will grant anonymous users access to these files. In Drupal 7, this could be done most simply with something like https://www.drupal.org/project/file_entity and granting the "View private files" permission to anonymous users. In Drupal 8, the attached patch (https://www.drupal.org/files/issues/2461845-demo-do-not-test.patch) can be applied to Drupal core for testing purposes.
-
Create an unpublished article node with an image attached. Because the article is unpublished and anonymous users can't view unpublished content, they should not have access to the image either. You can verify that this works; if anonymous users access the image file directly by URL, they will not have access to it.
The intention of this setup is as follows: The site owner wants to make sure anonymous users can view a whole class of private files by default. But if the private file is associated with restricted content, it should inherit those restrictions and not be viewable by everyone. (For example, the image might be a top secret diagram that is not ready for the public to view.)
-
Now remove the file from the node, or delete the node (either should work). If you try to view the file as an anonymous user, you will now suddenly have access to it. This is a security issue. It will only be accessible for 6 hours by default, but because of #1399846: Make unused file 'cleanup' configurable the window can be configured to be longer than that, including infinitely long (via the "Delete orphaned files after" setting at admin/config/media/file-system).
An unpatched Drupal 7 site deletes the file right away, so does not experience this security issue.
I am not sure how best to fix this. Rolling back the changes from #1401558: Remove the usage handling logic from file_delete() would probably do it but doesn't sound like a great idea. Maybe there needs to be code to more aggressively block access to files that are not attached to any entity anymore?
| Comment | File | Size | Author |
|---|---|---|---|
| #43 | interdiff.txt | 2.75 KB | fabianx |
| #43 | private_files_that_are-2461845-42.patch | 4.98 KB | fabianx |
| #17 | some-file-thingo-2461845.doesnt-pass-wa-wa.patch | 5.01 KB | larowlan |
| #17 | some-file-thingo-2461845.fail_.patch | 2.54 KB | larowlan |
| #12 | file-download-forbid-neutral-2461845-12-interdiff.txt | 482 bytes | berdir |
Comments
Comment #1
David_Rothstein commentedComment #2
xjmYowch. Seems like something we need to fix before the upgrade path is supported since I imagine this would be an SA if it did impact D7 core.
Comment #3
berdirIn file_file_download(), if a file has no references left, it doesn't explicitly check access and allows other modules to chime in. That's why this happens.
Hm. I understand that the change for D7 might be a problem, but I'm not sure that it's just by design for D8.
As you said, you need a module that actually grants access, like "View *private* files". Which is defined as an admin permission:
I would actually consider it to be strange/a bug that I can't access private files despite having that permission? What's the point of that permission then?
And your hook explicitly grants access to *all* files. Again, not very surprising?
That said, I do see that point that is unexpected when access was denied before and then it no longer is. But as mentioned above, in those scenarios, I'm not actually 100% sure that' desired? It might be a logical next step to make files their own thing and not just attachments to other entities that have no existence beyond that, to not deny access anymore? So maybe instead of trying to find workarounds to force denying access to those fields, maybe we should instead make a step forward and remove the -1. That means other modules need to consider private files properly, but again, access code always need to be correct or it is an issue.
Comment #4
David_Rothstein commentedGranting access to all files to anonymous users was just the easiest way to test this - it doesn't have to be that broad of a permission. All you really need is a hook that grants access to the one particular file in question.
Maybe a more realistic way to reproduce this in practice would be:
It is not far from a use case I saw on an actual site a year or two ago (although that one was even more complicated).
So if we did it that way, I'm not sure how the above situation would get resolved.
Personally I think Drupal should ideally distinguish between files that are intended to be "only attachments" and files that are intended to be able to "stand on their own". (See my comment at #1399846-74: Make unused file 'cleanup' configurable, last paragraph.) It was rightly pointed out that would be a big change and probably Drupal 9 material, but I think it addresses the heart of the problem. Because then File module would know which files it should be checking access for (only files in the first category), and for those files only it could also go back to deleting immediately when they are no longer attached to anything, like Drupal 7 does.
Comment #5
quicksketchAny module that is granting access to all private files that is by extension or any other criteria other than registered records in the database
would likely open up security vulnerabilities (as described a couple of different ways above)intentionally is granting access to a file with limited knowledge of the file usage (which generally is a bad idea). If the file is a "stand-alone", a file_usage record should still be registered by the module that provides the stand-alone capability (e.g. File Entity in D7).I think this might be a good idea as well, but in the mean time, any file that stands on its own should simply point a file_usage record at the file entity, ensuring the file never is deleted because it's always "in-use" by its own entity (unless that file entity itself is deleted).
So far from all the examples given, they all seem like any problems would be derived from a module implementing hook_file_download() and returning access when they didn't have enough information to be making such a judgement.
I think @Berdir is correct that the thing we're missing here is consistency in granting access to a file. File module should probably *not* return a -1 access for files that may have been attached to a node but to which the user does not have access. If another module wants to grant access to that file (because it may have originally been uploaded through a file browser or some other means), then that module should be allowed to grant access.
EDIT: Amended my statement about causing security vulnerabilities (it doesn't by itself).
Comment #6
plachComment #7
gábor hojtsyAs per hook_file_download:
So is the idea that file_file_download() would
Instead of -1? That would mandate that all other file download hooks would need to do this access check themselves then?
Comment #8
gábor hojtsyNot sure how would that resolve the problem, given the "problem" is with the lines above that return:
Whatever is returned in the following lines, this will already return if there are no references left. While its true that a contrib module that manages a file directory should maintain usage records for the files, core already allows in Drupal 8 to keep all files (without usage records). And I don't think we want to keep a usage record for each file referencing itself, because then no file would be ever deleted. Maybe we can keep a usage record for each file if the site is set to keep all files globally, but then we need to update all files when that setting is changed, so the setting change actually results in files removed later. And then you still have this window of the file being accessible until actually deleted which is part of the report too.
Comment #9
berdirI'm not sure how adding a dummy file usage would actually help, that would then still either always allow access or never. So we're back to the question what we should do with those files. Also, how would we remove files then that actually have no usage left and should be deleted?
The thing is that we return a different value for those two cases.
* If there is no file usage, we return NULL, so we don't grant access but we also don't explicitly deny. (access is still denied, unless someone else allows access)
* If there is file usage, but the user doesn't have access, we explicitly deny access.
I think we should be consistent. In which direction, I'm not completely sure, but I'm definitely leaning towards returning NULL in both cases, and allow other hooks to still grant access. That is consistent with how entity access works. Why is it so bad here? If you're implementing file access hooks, then it is your responsibility to handle it correctly.
Also, entity access has three states now. So we actually could differentiate between neutral and explicitly deny there as well, in that case, we would only explicitly deny if access('download') explicitly denies?
Comment #10
berdirMy suggestion would look like this. Completely untested, want to see what the test output is.
Comment #12
berdirUhm, that's what I get for not testing...
Comment #13
gábor hojtsy@Berdir: Are the removed conditions (owner, permanent) handled in the access method directly? Looking at \Drupal\file\Entity\File it does not seem like.
Comment #14
berdirYes, they are in FileAccessControlHandler.
The check there didn't actually check access, it just allowed the download access implementation to run which then again checked access. We're still doing that.
Comment #15
slashrsm commentedI must confess that I am a bit confused and not completely sure about the intent of this ticket. It appears to me that we're trying to solve a security issue that *might* be caused by a poor code or administrator giving out permissions to wrong people.
However, patch in #12 looks good to me. It makes thing more consistent and easier to understand. +1 to get it in.
Comment #16
larowlanworking on a test
Comment #17
larowlanSo here's a test based on the OP.
It fails but doesn't pass with the patch from #12
So either the test is wrong, the steps in the OP are wrong or the patch doesn't fix the issue.
I'm tipping it's one of the first two options.
Hoping someone else can shed some light on it.
Comment #18
berdirMy patch isn't supposed to fix the described problem, because I think that is not a bug, it's by design. If I have the "View private files" permission (which has restrict access => TRUE), then I expect to have access to *all* private files. Right now, I only have access as long as they are either not used anywhere or I do have access.
Imagine this: You upload a file with file_entity as a private file, and you have access to it. Until someone decides to use that file for a node that you don't have access to (e.g., the node is not yet published). Now you can't see your file anymore?
So yes, if you implement the hook like this, you have a problem. But that's the same for hook_node_access(), if you return Access::isAllowed() from yourmodule_node_access(), then your site is wide open too. That's not a bug?
My patch is just making the behavior consistent and behaving the same way, so that it doesn't make a difference if there is a file usage without access or not file usage. We don't grant but also don't explicitly deny access. Unless a field/entity access explicitly returns forbid(), then we do forbid access.
So, a test for my patch would test the difference between a field or entity access returning an explicit forbidden and neutral. neutral is identical to no file usages. I'm not 100% sure that is correct (what if a file is used on two fields and one explicitly forbids access? I guess you still want access). Which would actually simplify the patch even more, we'd just have an else/if and FileAccessControlHandler doesn't need to be changed.
Comment #19
larowlanTagging for issue summary update, as OP is wrong
Comment #22
David_Rothstein commentedI'm retitling this a bit since I agree that the security issue here really is mostly about the before vs. after inconsistency.
I think the current design is clearly that the File module intends to "own" any file that is used as an attachment (regardless of where else it might be used) and thereby ensure that it's inaccessible based on its attachment status. The scenario I wrote up is based on the current design.
Changing the design to be the opposite is an interesting proposal, though... I agree that it would improve some things and would make file access more like other types of entity access.
Where I still get stuck, though, is that if the design is changed, people here are basically saying that no module should ever grant unprivileged users blanket access to a group of files.
Comparing that to the node access system, wouldn't this be the equivalent of removing the core behavior where users with "access content" permission can see all published nodes by default, and then decreeing that no contrib node access module should attempt to add anything like that permission on their own? How would that work?
In the node access system, we certainly do expect that you can give the "access content" permission freely to unprivileged users without giving up on the idea of implementing node access on your site...
Comment #23
gábor hojtsyI looked at least how file and node access is documented, not sure that gives a helping hand in understanding this problem:
So for files:
For entities:
So the docs on file access are contradicting, it both says that if at least one grants access (returns headers) it is downloaded and also says if someone returns -1 it will not.
Comment #24
berdirNot sure I see why they are contradicting?
They are really the same IMHO:
By default, access is denied.
headers == allow
Not returning anything == neutral
-1 == forbidden
Hm, not a full answer, just some thoughts:
* Private files are already a subset of all files.
* If you want files to be available by default, then don't make them private files.
* access content is a special permission, because without it, we won't even check anything else. There is no equivalent in file access and also no generic entity access concept for that, that's why it is checked in access(), before even calling any hooks
* Even if all your files are private files, as long as they are attached to nodes that are available to anyone anyway.
I still don't see any use cases that would make sense to have that would break..
And what you are saying also completely depends on the core way of using private files, that they are uploaded to nodes and start with having file usages. When you use file_entity/media, then you can upload files without already having usages, and when you create them as private files then, then they would also be visible if such code would exist.
Comment #25
berdirSetting back to needs review. I still think that this is not a security issue but only a non-critical inconsistency.
I'm not sure if the patch in #12 is OK, or if we should remove the neutral/forbidden behavior. The question to answer is this:
Assuming that a file is attached to two different nodes (1 and 2), what is the expected behavior:
Right now, you have access for the case A / F, and it probably has to stay like this, otherwise you can view the node, but the file won't be visible. Which I doubt is the expected case.
Comment #26
webchickTentatively tagging.
Comment #27
toamit commentedLast week I created another issue for core that can potentially handle these and more complex cases well.
Please take a look at https://www.drupal.org/node/2485807
Comment #28
fabianx commentedSo I think there is a much much simpler fix:
- Deny access to temporary private files with usage 0 by default.
And we talk about downloading, so that should be okay - if it is no longer attached anywhere ...
As Access + Deny == Access contrib is fine, too.
I am getting a patch for that going ...
Comment #29
fabianx commentedThe other part of the problem (and why file_entity is a really bad example) is that file_entity in D7 never checks hook_file_download_access() and it also uses the 'view' permission for the entity instead of the 'download' permission. (I just had the pleasure to circumvent that for a client ...)
So the real problem is the broken/strange API around this, but that problem is pre-existing in D7 and hence not a release blocker - unless we allow someone to access 'deleted' files in the race after it was deleted till cron runs.
That API is broken because:
- hook_file_download() can just allow access by sending headers.
- Only file_file_download() calls file_download_access_alter(), but works only on usage by entities
TL;DR: This is pre-existing strange in D7, lets fix the critical issue of the race when the file was deleted in D7 and when it is deleted lazily in D8.
Comment #30
fabianx commentedAnd fixed the tests and added a proper fix.
That puts behavior almost back to where it was in D7.
It just means that if some module wants to grant access to temporary files, they need to make them either:
- permanent
or
- add file usage
Given that system_cron could run at any time (race conditions anyone? ... follow-up though), that is a good thing in general though.
And to have the owner of the file still being able to access the file should be okay ...
Comment #32
fabianx commentedFixed the test failures, the test depended on the old behavior, which nothing except tests would do like this.
Also the file was created for uid 1 by an anon user, which does not really much sense ( in a real world usage) ...
Comment #35
fabianx commentedComment #36
benjy commentedhook_file_download() isn't document in the file api file. I was looking specifically to see if the return -1 was meant to be a constant or such. I see FileDownloadController::download() specifically checks for -1 though, so all good.
The rest of the patch looks pretty great.
Comment #37
berdirHm.
Deleting temporary files is actually optional now. I'm wondering if that has any impact on how this works, not sure.
I still think it would be better to go in the other direction but if that's what it takes to get rid of this issue, then so be it.
Comment #38
David_Rothstein commentedThis is interesting, and I think it might work. It is sort of what I was talking about in the issue summary originally ("Maybe there needs to be code to more aggressively block access to files that are not attached to any entity anymore") but for some reason I didn't think of doing that only for temporary files, which definitely does make it a lot simpler.
For hook_file_download(), Access + Deny => Deny actually (see FileDownloadController::download). So what that means is that by returning -1 as introduced in this patch, the File module essentially prevents any other module from granting access to a temporary file.
Are there scenarios where that's a problem? I'm not sure I can think of any. It does sort of reintroduce the requirement (at least for private files) that modules which want to keep an unused file around, e.g. in a "media library" that multiple admins need access to, must add a file usage record for it, so that it always stays permanent. Removing that requirement was sort of the main goal of #1399846: Make unused file 'cleanup' configurable (see the issue summary there)... I am not sure it was a totally valid goal, but it needs some consideration.
But overall I think this makes sense to me, and this patch might be the quickest (even if not the most ideal) way to close this issue.
Comment #39
David_Rothstein commentedThis comment is not quite right - the code is checking references (not usage), and system_cron() will not necessarily ever delete the file; it depends on how temporary file cleanup is configured on the site.
Probably better to have the code comment explain the security rationale instead?... i.e., a temporary file might be temporary because it no longer has references, so we deny access in case those references would have denied access if they still existed.
Yeah, um, that's pretty confusing to explain :)
---
I'd still love to see if what I mentioned in #4 is possible instead:
To the extent that it's just adding a new boolean flag to files, maybe it's not really Drupal 9 material after all? But I won't have time to look into it right now.
Comment #40
dawehnerSo could we fix the criticality of that issue and maybe be too harsh about that for now and then follow up on it later?
Comment #41
fabianx commentedWell, they should not make it temporary then. Or at least put it back to permanent afterwards, but that is only a major bug - if at all.
Temporary files with no references / no usage should not be around forever. That completely subverts what 'temporary' means.
Because the file is now not deleted directly, they can still make it "permanent" again. Not totally pretty, but workable.
---
That is a very good point. Lets check for usage() instead first and then deny if usage == 0 && temporary (and not the owner of the file).
Then keep the references code intact, even smaller patch. And then the comment is correct :D.
Comment #42
David_Rothstein commentedHm, yes, I think checking usage makes sense and would be safer.
Some people from #1399846: Make unused file 'cleanup' configurable could still complain about all this, but if so it's fixable - and they were mostly interested in public files anyway (which is not affected by this patch one way or the other).
One more problem with the code comment:
It's actually file_cron() in Drupal 8.
Comment #43
fabianx commentedAnd here is the patch as promised, which is now even more explicit about things and also improved the documentation.
Yes, file_cron() is also fixed in this patch. Thanks! (Could file a follow-up for BaseFileBase::delete() as that still refers to system_cron(), but out of scope here.)
Comment #44
dawehnerIts good that we have a pretty good explanation of what is going on.
Comment #45
slashrsm commentedIssue summary seems to be pretty outdated. Do we need an update?
Comment #46
catchYes that'd be useful - both for committers and for anyone who finds this issue one day via git blame.
Comment #47
slashrsm commentedUpdated issue summary. Moving back to RTBC. While working on this also noticed a minor issue in documentation (#2489606: hook_file_download() docblock refers to a non-existing function file_download()).
Comment #48
fabianx commentedAdded some to the summary I had already written.
Comment #49
alexpottThe patch attached closes the security hole and behaviour change from Drupal 7 detailed in the issue summary. If contrib wants to change the behaviour it can.
Committed dd0712a and pushed to 8.0.x. Thanks!
Comment #51
yesct commented