Problem/Motivation
This blocks #2429617: Make D8 2x as fast: Dynamic Page Cache: context-dependent page caching (for *all* users!).
When a form is cached (by render cache if it's in a block or by SmartCache if it's part of a response), its form action is bound to whatever happens to be the first request URL to need that form.
public function prepareForm($form_id, &$form, FormStateInterface &$form_state) {
…
// Only update the action if it is not already set.
if (!isset($form['#action'])) {
$form['#action'] = $this->buildFormAction();
}
…
}
protected function buildFormAction() {
…
return $parsed['path'] . ($parsed['query'] ? ('?' . UrlHelper::buildQuery($parsed['query'])) : '');
}
In other words: any form that doesn't have $form['#action'] set, gets that generated automatically, based on the URL's path and all query arguments.
Proposed resolution
When FormBuilder automatically generates $form['#action'], also associate the 'url.path' and 'url.query_args' cache contexts.
But this would cause many variations. So, instead, render the form action using a placeholder, and associate those cache contexts with the placeholder result.
Remaining tasks
Review.
User interface changes
None.
API changes
None.
Data model changes
None.
Original report
If you have a form that doesn't define its own $form['#action'], and if that form appears inside a cached block, the $form['#action'] that is initially used when the form is built gets stored in the cache.
This means that whatever URL you view the form on later, submitting the form will submit data to the original URL that the form was first viewed on.
This isn't so terrible really, but it's unexpected because many forms just do a drupal_set_message() after submitting and expect you to remain on the same page afterwards.
It would be pretty weird to submit a form on the homepage and be randomly redirected to node/23456 after you submit, but that's what can happen as a result of this bug.
I will have a test patch (written for another issue) that demonstrates the issue which I can link to here shortly.
Beta phase evaluation
| Issue category | Bug because incorrect cacheability metadata, which leads to incorrect caching. |
|---|---|
| Issue priority | Major because unexpected behavior, and blocks SmartCache (which is ac critical perf improvement). |
| Prioritized changes | The main goal of this issue is cacheability and hence performance |
| Disruption | Zero disruption. |
| Comment | File | Size | Author |
|---|---|---|---|
| #112 | blocks_containing_a-2504139-112.patch | 19.64 KB | plach |
| #40 | interdiff.txt | 585 bytes | borisson_ |
| #38 | interdiff.txt | 707 bytes | borisson_ |
| #37 | interdiff.txt | 9.72 KB | borisson_ |
| #34 | interdiff.txt | 9.59 KB | borisson_ |
Comments
Comment #1
David_Rothstein commentedThe patch at #2504141-1: Information disclosure/open redirect vulnerability via blocks that contain a form can be used to manually test this issue. Just add the "Test form" block to the site, and when configuring the block make sure to leave the block cache maximum cache on (i.e., its default setting).
Comment #2
David_Rothstein commentedSo I guess you can say that if the block contains a form, it's up to the code that builds the block to know about this issue (and set an appropriate cache context so the block is cached per page). That is sort of how it works in Drupal 7.
But I don't think any core blocks do this? And it sort of seems like it should be the form's responsibility, not the block.
Comment #3
fabianx commentedI think the default #action should be a placeholder (e.g. if no-one else set the action) that is always POSTing to the currently active URL, but because that means the form should have no state in itself, this is just possible when caching forms on GET requests is gone ... (#2502785: Remove support for $form_state->setCached() for GET requests)
Making forms cacheable just per page is ... problematic.
Comment #4
wim leersNice find!
Comment #5
Maddie35000 commentedThk ! Good to know.
Comment #6
prashantgoel commentedHey Everyone,
There are two scenarios to this.
1. When the form is doing drupal_set_message its going to system/404.
2. When the form is showing an error it goes to the first URL form was viewed.
Attached a patch for testing if required.
Thanks
Special thanks to @valthebald for helping me with this issue.
Comment #7
prashantgoel commentedAttached the revised patch. Last one was not complete.
Thanks
Comment #8
fabianx commentedComment #10
prashantgoel commented@fabianx: my patch is only for testing reproducing this issue and it doesn't solves the issue.
Thanks
Comment #11
NicolasBylra commentedHi, thanks for sharing that, had a lot of trouble with that issue tho.
Comment #12
legolasboSetting this back to needs work because the patch doesn't solve the issue, but only demonstrates the problem.
Let's change the patch to be an actual failing test so we also have a regression test when the issue is solved.
Comment #13
borisson_Added a new test that fails because of this.
Comment #15
borisson_Cleans up the code in the test and improves the test, nothing done to make the test pass yes though, so it doesn't.
Comment #16
wim leersSo the bit of code that needs to be updated here is
There are two possible ways to solve this:
urlcache context with$form. I.e.$form['#cache']['contexts'][] = 'url';(Try that, that should make the tests pass.)$form['#actionto a placeholder, and attach this placeholder. I.e.$form['#attached']['placeholders'][] = …Look at\Drupal\Core\Access\RouteProcessorCsrf::processOutbound()(#2512132: Make CSRF links cacheable) for an example.Comment #17
borisson_Attached patch passes the test in the more-complex-but-good-for-cacheability way. I tested the easier way and that worked as well.
It needs more work, because right now we're still
route_processor_csrf:renderPlaceholderCsrfTokenin the#lazy_builder. I just wanted to make sure I was going in the right direction before continuing work on this.Comment #19
borisson_We're now using a method in the
FormBuilderclass as#lazy_buildercallback, so this is getting closer.Locally this fixed tests for
BlockContentTranslationUITest, let's see what other tests this fixes.Comment #20
wim leersThat's starting to look like something :)
This doesn't make sense; the placeholder is based on the already-built form action which is simply thrown away.
It can be simplified to something like
(It just needs to be a not-easily-guessable string.)
This
[$form_action]bit is wrong; the lazy builder callback doesn't need/use that.All of this can be simplified to:
Comment #22
borisson_Thanks for #20 @Wim Leers.
$form_id#lazy_builder property must have an array as a value, containing two values: the callback, and the arguments for the callback.I solved this by adding an array with
NULLas value. Is there an already established pattern on what argument to pass along when no actual arguments are needed?This patch doesn't functionally change anything to #19, but that patch made a big difference in test failures as expected. The remaining test failures seem to largely be related to "The specified #ajax callback is empty or not callable.".
Comment #23
wim leers#22.2: just the empty array should do.
Comment #24
borisson_This fixes #23.
Comment #25
wim leersAFAICT this can't work, because the placeholder is never set anywhere :)
This needs:
Comment #27
borisson_Running the tests with the change suggested in #26 locally makes take a very long time.
I cancelled a test that ususally takes at most 3 minutes (CommentPagerTest) after +- 25 minutes. Let's see if that's an issue on my machine only or that the bots agree this change is bad.
I also added more comments in the code so it better describes what's going on.
Comment #29
berdir@borrison_ There's a bug when running tests in the UI that have a fatal error. The UI just runs them again and again, without visual feedback. Refresh the page if you think that's the problem and it will show an error the next time it completes.
Comment #30
borisson_Since the previous patch makes the testrunner very unhappy, I reverted the change there. Because I can't get any feedback from the testrunner (both locally and the testbot) that is clear enough for me to figure out I'm just going to leave this patch here as is. I'll try to come back to it in the following days.
Comment #32
borisson_I think I found out what was going wrong.
The renderable array I was returning from
renderPlaceholderActionwasn't really a renderable array. This should be a better way to do this.Locally, this fixes the
BlockFormInBlockTestand the previously failing CommentPagerTest so that's a good sign.Comment #33
wim leersDid a complete review. Looking great :) Almost there.
Technically, this is actually perfectly cacheable: if you look at
\Drupal\Core\Form\FormBuilder::buildFormAction(), you see it relies on the entire URL. So, let's change this line to:Let's add a comment here:
(This comment is then analogous with the one in
RouteProcessorCsrf.)Also, in fact, we should NOT use the
$form_idin the hash, let's just use the hardcoded string'form_action'and concatenateSettings::getHashSalt()to prevent guessability.The beauty is then that all forms use the same placeholder. Which has as a nice consequence that if there are multiple forms on a page, that all of their action URLs will be set at the same time!
(We should add a test for that as well.)
I think a clearer comment would be:
(This comment is then analogous with the one in
RouteProcessorCsrf.)@inheritdocMissing trailing period.
This seems to be "floating". I think it belongs with the next code block? If so, let's put it just below the comment on the next code block?
80 cols.
Extraneous
\n.Extraneous
\n.Let's just
return \Drupal::formBuilder() …Comment #34
borisson_This patch fixes all the issues mentioned in #33.
Regarding .3, Not sure if there's a way to test that all forms use the same placeholder, or that they get replaced at the same time. I have included a new test that checks what happens when 2 forms are included on the same page. That should be enough to verify this?
Comment #35
wim leersIncomplete sentence.
I think this test can be vastly simplified, because the other test already verifies that the original bug is fixed. This test does not need to repeat that test, it only needs to care about the placeholder behavior in case of multiple forms. So, something like:
#post_rendercallback with it like\Drupal\Tests\Core\Render\RendererRecursionTest::testRenderRecursionWithNestedRenderPlain()has, this runs after the HTML has been rendered, but before placeholders are replaceddrupal_set_message(array_keys($elements['#attached']['placeholders']), which should output a single string, which is the placeholder used by both(It's a bit ugly, but otherwise you'd have to carefully render the render array in stages. This is less work.)
Comment #37
borisson_Fixes remarks from #36 and test failures of the previous patch (#34).
I just noticed I didn't fix all issues of #33 earlier. I'm not yet using
Settings::getHashSalt()to generate a placeholder. When I do, I get an error: (RuntimeException: Missing $settings['hash_salt'] in settings.php. in Drupal\Core\Site\Settings::getHashSalt()). I don't understand why this happens, the hash_salt is set in the settings file.Comment #38
borisson_As mentioned in #2463567: Push CSRF tokens for forms to placeholders + #lazy_builder, using
crc32instead ofhash('sha1', ...)should be sufficient.Comment #40
borisson_When changing the code, be sure to also change the test that accompanies that piece of code. Test should now no longer fail.
Comment #41
wim leersPatch is looking great :)
s/break/have poor/
Isn't this appearing in every
ContentTranslationUITestBasesubclass? If so, we should update the value in that base class instead.Comment #42
borisson_#41.1: fixed.
#41.2: doesn't appear in
TermTranslationUITest,CommentTranslationUITest,UserTranslationUITest. I think this override is a little bit more sane, but I can change it if you want.Comment #43
wim leers#41.2: makes sense, thanks!
So, with that, I think this is pretty much ready; would be great if e.g. Fabianx could review this.
s/form action/form action URL/
Tests that all forms use the same placeholder.Comment #44
fabianx commentedLets use:
'form_action_' . crc32('form_action');
in case someone tries to debug placeholders.
Nice! :) I love it!
You can have two users logged in at the same time?
Fascinating ...
I actually think the 2nd login removes the first one.
So probably the admin account is not needed at all for the test to pass?
Comment #45
borisson_Added the namespace for the placeholder.
Logging a user in was actually not needed at all the pass the test. Simplified the test.
Comment #46
tim.plunkettUnnecessary.
Otherwise, the rest of the patch looks good to me. A bit ugly, but that's the way the API works.
Comment #47
borisson_Attached patch fixes #46
Comment #49
borisson_Assuming this is an unrelated failure in ConfigImportInstallProfileTest. Retesting.
Comment #53
wim leersOk, then I think this is ready now, per #44 and #46 :)
+1. The placeholder/#lazy_builder API was primarily designed for render arrays (i.e placeholders that'd be replaced with HTML)
We already have
\Drupal\Core\Access\RouteProcessorCsrf::renderPlaceholderCsrfToken(). This will be another one. #2463567: Push CSRF tokens for forms to placeholders + #lazy_builder will be another one. All three need to render something other than HTML. Opened #2544220: Improve DX for non-HTML placeholders for that.Comment #54
catchWe should use hash('crc32b') - that gives you a string hash.
crc32() gives an signed int. And not only that but it's different on 32 vs. 64 bit, which would be fun given the placeholder will end up in the database and could potentially end up moving between those two.
See the docs on http://php.net/manual/en/function.crc32.php for more.
Slightly missed opportunity to increase the zoological content of core via #options, although that would also make the form a bit restrictive.
Comment #55
borisson_Fixes #54.1, left the form as is for #54.2
Comment #56
wim leersComment #58
borisson_So it looks like the previous patch was wrong, I had done the patch on a different branch and everything broke because of code from a different issue. Attaching what should be a correct patch and set this back to RTBC.
Sorry.
Comment #59
yched commentedSilly nitpick, but inlining the content of $placeholder_render_array would make the code a bit more fluent ?
Even more nitpicky : The explanation about *why* we use a placeholder would make more sense in the place where the "Generate a placeholder" code starts ? "Generate a placeholder and a render array to replace it" pretty much paraphrases the code below it and adds little value, it's the "why" that is interesting.
Feel free to ignore, esp. if #2544220: Improve DX for non-HTML placeholders comes and cleans up that code anyway.
Comment #60
borisson_I agree with @yched's silly nitpicks so I applied them. Not sure if that means I could've left this issue as RTBC so I moved it back to needs review.
Comment #61
wim leers+1, this reads much better!
@yched++
Comment #64
catchnits: trough. And no parens on drupal_set_message()
Comment #65
wim leersComment #66
borisson_Comment #67
wim leers80 cols violation… but I think catch can fix that on commit.
Comment #69
borisson_So, #66 was passing first. For some reason it doesn't pass anymore though. I've been trying to figure out why.
I had some help from
git bisectto pinpoint the commit where the failures are introduced and I think I've found it. So the attached patch is the same code as #66 with agit revertof the patch from #2501931: Remove SafeMarkup::set in twig_render_template() and ThemeManager and FieldPluginBase:advancedRender.This patch is just to verify that all tests pass without that code, if it does, we know where to look.
Comment #70
wim leers@borisson_++
Comment #71
dawehnerThat reads like a regression, doesn't it? I think you should rebase your most recent patch.
Comment #75
borisson_Rebased the patch, this is basically the same patch as the one that passed in #60.
Comment #77
borisson_Attached patch will break RendererTest but that should be the only remaining failures.
This is nog a definitive solution but this should be a good indication to start figuring out where this broke.
Comment #79
borisson_The patch in #77 didn't apply anymore. The attached one does, still the same failures as in #77 though.
I still have the same reservations about the "solution" applied in #77 to resolve the errors.
Comment #81
wim leersFirst, straight reroll.
Comment #82
wim leersHere's the correct solution for #77.
Comment #83
wim leersAnd here are fixes for the last few failures. This should be green (unless #81 has failures in different tests than #79).
Next step:
['url']is inaccurate; what is actually being varied on is['url.path', 'url.query_args']. That's less specific than'url', and hence more optimal.Comment #84
wim leersAddressed my own remarks from #83.
I noticed that the rebase was incorrect in
BlockContentTranslationUITestso I expect all patches above to come back red.Comment #85
fabianx commentedThis looks great to me! And is hopefully way better than having to cache all blocks that contain a form by url.path and url.query_args ...
Comment #86
wim leersSo there were new failures. This fixes those.
IS updated.
Comment #87
effulgentsia commentedAt this point in time, blocker for #2429617: Make D8 2x as fast: Dynamic Page Cache: context-dependent page caching (for *all* users!) = Critical, so promoting.
Comment #89
fabianx commentedBack to RTBC. the test changes make sense.
Comment #90
dawehnerThis line is a bit confusing, why would we care about the hash at all? Why would 'form_action' be in any level less specific. Don't we want to include some information of the form onto the placeholder so we have a unique placeholder across forms?
Comment #91
wim leers#90: #33.3 answers this:
We have a test for that. But for some reason we lost the salt along the way.
Comment #94
dawehnerSo needs work?
Comment #95
fabianx commented#90: Good question, we should probably add a comment there.
The reason is that we don't want 'form_action_' on this page to be replaced with '/node/2504139'.
The crc32b would still allow me to write a comment, where I use 'form_action_f0e3' and it gets replaced with '/node/2504139', so there is no security protection here, but its unlikely that happens during normal text.
We could theoretically make the placeholder be: 'action="form_action_$hash"' that we then replace with more, but not sure that really helps.
Usually the hash should be sufficiently different.
Comment #97
wim leers#94: +1 for NW.
All fixed. The tricky bits you saw I copied from the patch in #2478483: Introduce placeholders (#lazy_builder) to replace #post_render_cache.
Comment #98
dawehnerNitpick: uses instead of users, you are drupaldoomed!
Comment #99
wim leersI need to go AFK.
Comment #100
dawehnerBetter do so!
Comment #101
wim leersComment #102
David_Rothstein commentedI am not sure this is a good idea security-wise. It is putting information about the hash salt in a variable that could wind up who-knows-where .... maybe even in the HTML under some circumstances?
I don't see why it's needed; why not just a hardcoded value like 'form_action_kjdsklfdsjlajflsdfasaslkdas' that no one will use by accident? Who cares if it's the same on all Drupal sites?
"trough" => "through"
Comment #103
effulgentsia commentedRelated to #102, we seem to have a mix of hashing algorithms and seeds for placeholders in HEAD plus this patch:
I think a dedicated service for generating placeholders would be good. Perhaps with 2 methods, such as
asHtml()(for the cases where the placeholder can be HTML markup, like the bottom two examples above) andasIdentifier()(for the cases where the placeholder needs to appear inside an attribute value and not be altered by functions such as Html::escape(), Html::cleanCssIdentifier(), UrlHelper::stripDangerousProtocols(), etc., like the top two examples). Then such a service could be well documented for why the chosen hashing algorithm and seed have the appropriate level of security.But, if all that is too much scope for this issue, I think just matching what's already in HEAD for RouteProcessorCsrf and using something like
hash('sha1', $form_id);could be good enough for now.Comment #104
fabianx commentedI agree that we should not duplicate the code all over core for that and I think the placeholdering service should indeed provide that.
However I also agree with #103 that that is a non-critical follow-up, because one of the auto-placeholdering patches adds a PlaceholderGenerator service like that anyway.
I however also agree with #102 that any random suffix would be enough for now.
Comment #105
plachWorking on a patch addressing #102. I'll create a follow-up issue for #103.
Comment #106
plachCreated #2562341: Add non-HTML placeholder generation to PlaceholderGenerator or a new service to address #103.
Comment #107
dawehnerI think the point was that we should use the same placeholder for all the forms, see #2504139-91: Blocks containing a form include the form action in the cache, so they always submit to the first URL the form was viewed at
Comment #108
plachMaybe this, then?
Comment #109
plachComment #110
wim leers#103: We've been migrating all placeholders from using SHA1 to using CRC32. We must've missed one instance.
#104:
+1 for a non-critical follow-up.
+1 for a
PlaceholderGeneratorservice, which incidentally is exactly what #2543334: Auto-placeholdering for #lazy_builder with bubbling of contexts and tags is adding (as Fabianx was already indicating).#106: Thanks for filing that!
#108: That looks fine to me :) Let's see what testbot thinks.
Comment #111
wim leersI overlooked it first, but #108 uses SHA1, it should use CRC32 (
hash('crc32b', …)).Comment #112
plachHope it's ok now :)
Comment #113
fabianx commentedRTBC!
Looks great now! :)
Comment #114
alexpottThis doesn't need to be here... It can go in the Renderer::render - here...
Comment #115
alexpottFrom @Wim Leers in IRC
That is a good reason to do that there.
Comment #116
alexpottCommitted feb0fc2 and pushed to 8.0.x. Thanks!
So I think we're missing test coverage of this. However, it's only indirectly part of the critical so let's do that in another issue.
Comment #118
berdirI seem to have a problem with this.
This caused test fails in simplenews: https://qa.drupal.org/pifr/test/937688
For some reason, it seems that the lazy builder is not called and the form action is *not* updated. The result is that the form is posted to the placeholder string (POST http://d8/form_action_cc611e1d returned 404 (5.45 KB).). I don't know yet why this happen and what's different, how could this not be called?
Any help appreciated.
Comment #119
janlaureys commentedI have the same problem as #118 but with all regular node forms, $form['action'] is NULL so I guess normally it would submit the form the current page, but right now the form action is set to form_action_cc611e1d so it submits to node/add/form_action_cc611e1d and it 404's. (Running BETA15)
Comment #120
fabianx commented#119: I tested beta15 on simplytest.me and could not reproduce that problem. Any ideas or steps to reproduce?
Comment #121
berdirI don't know about #119, it obviously doesn't seem to happen for everyone, but the simplenews test failures are consistent on at least three different environments (testbot, local, d8status), so that should be reproducable. And I'd guess if we can figure that out then that will also help with the problem described in #118.
I did find a problem with lazy builders in #2565501: Tests are not working because Dynamic page cache was committed in core., see my comment with the patch, but I don't think it's the same here.
Comment #122
janlaureys commentedIt's on a content type with quite a few fields from contrib modules that I have patched though (geofield, paragraphs). I'll do a few tests removing some of those fields and will report back.
Edit: it seems to be a problem with the geofield module.
Comment #123
berdirQuick update if someone is running into a similar problem.
In the simplenews case, it was because some custom code added another #attached element and didn't correctly merge in existing #attached elements. Worked fine before since there weren't any but now there are.
Comment #125
claudiu.cristeaI have the next case in a module: In hook_form_alter() I need the real value of $form['#action'] but I'm getting the
form_action_*placeholder instead. How can I get the real URL?Comment #126
claudiu.cristeaFinally I did this but I don't like it:
I think
buildFormAction()should go in the FormBuilderInterface as public method.Comment #127
wim leersThis caused a regression elsewhere in core due to missing test coverage: #2649404: Form API's #https is broken for user login block (no longer opts in form to use HTTPS action URL). #2342593: Remove mixed SSL support from core (or earlier) should've added test coverage, that'd have prevented this regression from being introduced.