Problem/Motivation
From #2983179-16: [META] Implement stricter access checking for the media library:
Right now, the media library depends on a state value object derived from URL query parameters. Users are not supposed to tamper with those parameters, but we have no mechanism in place to prevent that. So we need to implement a tamper-proof hash, similar to what Media does in its IframeUrlHelper class, to prevent people from messing with the query parameters in the first place.
Proposed resolution
Implement a tamper-proof hash, similar to what Media does in its IframeUrlHelper
class, to prevent people from messing with the query parameters.
Remaining tasks
Write patch
Review
Commit
User interface changes
None
API changes
None
Data model changes
None
Release notes snippet
Comment | File | Size | Author |
---|---|---|---|
#23 | 3038241-23.patch | 18.33 KB | seanB |
#23 | interdiff-21-23.txt | 1.73 KB | seanB |
#21 | 3038241-21.patch | 18.74 KB | Wim Leers |
#21 | interdiff.txt | 1.02 KB | Wim Leers |
#20 | 3038241-20.patch | 18.41 KB | seanB |
Comments
Comment #2
seanBHere is a first patch to implement a hash for the state.
Comment #3
seanBComment #5
seanBTestbot had issues, #2 is green.
Comment #7
phenaproximaIs there any reason for this to be a static method we pass parameters into? Seems to me like we could have a public getHash() method to do that (which would not need any arguments except the private_key service, which could be passed if needed to facilitate unit testing), like so:
And then we could even have an isValid() method which calls it, like so:
Overall I think $state->isValid() might be a cleaner encapsulation.
Having this class throw AccessDeniedHttpException seems like overreach. IMHO, the calling code should be responsible for asking the state object if it is valid, then taking appropriate action.
Whoa. Couldn't we just do
$link_state = clone $state;
here? (We'll need a comment either way.)Comment #8
Wim LeersLooking good :)
, to prevent a malicious user modifying the query string to attempt to access inaccessible information.
🤓 You could even turn this into a
\Drupal\Core\Http\Exception\CacheableAccessDeniedHttpException
and passurl.query_args
as the cache context!🤔 Why do we need these to be sorted? Yes, it affects the hash. But why/how could the order by different?
👍 Using hash salt
Nit: I personally think
->statusCodeEquals(403);
would be clearer?You are using descriptive strings as array keys for the data provider, great! But you're also providing comments. The comments should be unnecessary, the strings should capture it all. That makes going through test failures ten times easier. So, in this case:
$test_data['a modified (invalid) hash triggers a 403']
, for example?Comment #9
phenaproximaTagging this as a beta target for 8.7.x because it's a minimally disruptive, non-user facing security improvement for Media Library. The only way this patch will break anything is if you've been tampering with Media Library's query parameters ;)
Comment #10
phenaproximaSean and I discussed this in Slack today. We hashed out a compromise...
We'll make getHash() a non-static protected method. It's okay if it uses \Drupal::service(), since that doesn't actually prevent unit testing (it just means you need to call \Drupal::setContainer() during test setup). This way, we can call it without any arguments.
fromRequest()
will instantiate the state object, call its protected getHash() method, and throw BadRequestHttpException, not AccessDeniedHttpException, if the hash is invalid. If the calling code wants to return a 403 if the hash is invalid, it can catch and wrap the BadHttpRequestException.Comment #11
Wim Leers😆
Comment #12
phenaproximaI raised another concern with Sean, and wanted to copy/paraphrase our discussion here:
So basically, if an opener wants to add and maintain additional query parameters beyond the ones Media Library already does, that opener is fully responsible for validating those parameters, either by hashing or by some other method. This seems acceptable to me, but I would appreciate a +1 from a framework manager or security team member.
Comment #13
alexpottI think
makes sense to me. You can't be responsible for things you don't know about.
Comment #14
seanB#7
1. Fixed. For isValid(), I used
isValidHash()
as a method name to make it a bit more clear, and pass the hash we want to validate. We need to do that since the hash we want to check comes from the request, not from the state object we have created.2. As discussed on Slack, when leaving this to the calling code it is relively easy for developers to get it wrong. The
fromRequest()
method should throw an error, and if you don't want the validation the calling code should use the create() method to build its own state object. Changed it toBadRequestHttpException
since we agreed that the calling code should decide what to do when the hash is invalid, not thefromRequest()
method.3. Affraid not, each link has a custom hash since the
media_library_selected_type
parameter is different for each link. Added a comment though.#8
1. Fixed.
2. Not sure how that works? Couldn't find how to create a
CacheableDependencyInterface
object from the request? The access denied is now returned by the access check in the latest patch, so this might not longer be necessary?3. Fixed.
4. Yay!
5. We can't do that in JS tests #2827014: Throw an exception when testing status code or response headers in functional JavaScript tests
6. We are doing the same in
providerCreate()
where the comments could become quite long. I would prefer to use a similar style for both.Comment #15
phenaproximaPartial review...
Is there any reason not to do this in __construct()? If so, let's expand the comment to explain why.
Thanks! This makes the method much more readable.
I don't think we need to pass the default value to $query->get().
Needs to start with (optional).
We should call our own methods here, like $this->getOpenerId(), $this->getSelectedTypeId(), etc.
Comment #16
seanBFixed #15.
Comment #17
phenaproximaGiven the fact that isValidHash() doesn't allow us to pass in the private key service, I'm thinking we can just go ahead and use \Drupal::service() without worrying about injecting it. Unit tests can easily call \Drupal::setContainer() if they need to bother with this.
I wish we could use PHP 7.1, then we could
catch (BadRequestHttpException | \InvalidArgumentException)
.Why is this line removed? Seems like it might be important to maintain it?
This doesn't seem like it belongs...
Comment #18
Wim Leers#14:
🤔 We're using object$foo
to modify object$foo
. Wait, what?!Addressed in #16 👍
Why is this optional?
🤔 Why?
Comment #19
Wim LeersOn second thought, this is not worth making cacheable. If anything, we don't want it to be cacheable, because there'd be too many variations to cache, plus it's not an expensive response to generate anyway, so this'd be a waste of caching space.
Comment #20
seanB#17
1. Fixed
2. :(
3. We are no longer re-using an existing state object for the links, but we are building a new one. When creating a fresh state object we automatically remove extra URL parameters.
4. Hmm not sure what happened here.
#18
1. :)
2. Fixed. See 17.1.
3. I think what you wrote in #19 sums it up quite nice :)
Comment #21
Wim Leers#20.3: hah! But the access result itself is cacheable. The access checking result is going to stay exactly the same so it shouldn't get
max-age=0
. I agree that we shouldn't cache the error response. Right now there's a problem with correctness/accuracy of the access result. The resulting response is never going to be cacheable, we don't need to tweak the access result to make the response uncacheable!Comment #22
alexpottIs the hash invalid or the other params? I'm not sure this message is correct. In other situations we do things like
throw new BadRequestHttpException('Invalid contextual ID specified.');
I think we should say something likeInvalid media library parameters specified.
Otherwise this looks great.
Comment #23
seanBFixed #22.
Comment #24
alexpottThanks.
Comment #25
phenaproxima+1 for RTBC. Nice improvement!
Comment #26
alexpottCommitted and pushed 2ab4c46707 to 8.8.x and f7c0c98edb to 8.7.x. Thanks!
Comment #30
Jody LynnWhen I attempt to use the views exposed filters in the media library widget I get the invalid hash error due to my hash having '_wrapper_format=drupal_ajax' appended to its end.
Comment #31
seanB@Jody Lynn only the media library state parameters should be part of the hash, the special AJAX parameters and views filters should not be a problem.
Could you maybe check again with the latest 8.8.x version and file a new issue if you still run into any problems?
Comment #32
almunningsNoticed I was getting the exception error on pagination, and someone else did too.
Is there a reason an empty entity_id would appear in one hash and not in another?
Related: https://www.drupal.org/project/drupal/issues/3067041