Problem/Motivation
There are several things like token processing, CSRF links, and potentially other things, where metadata gets lost due to Drupal's usage of strings.
If the user is within a 'rendering context' (e.g. the renderer is active and there is a stack frame) then this meta data gets nicely bubbled up and stored.
However, there are cases, where there is no such stack and then this data gets lost, which is a potential security issue. (i.e. if cacheability metadata is lost, then cache contexts are lost, which means that Drupal may end up caching without taking those cache contexts into account, leading to information disclosure.)
Solving this would also solve #2351015: URL generation does not bubble cache contexts.
Proposed resolution
- Enforce a rendering context as mandatory.
::renderRoot()and::renderPlain()do this automatically, so 99.9% of code doesn't need to change. - Provide controllers with a default rendering context, that is used and merged when they return a render array. (See
EarlyRenderingControllerWrapper.)
Remaining tasks
Review.
User interface changes
None.
API changes
- All rendering must happen within a render context. Most code is unaffected. Affected code was insecure. (See beginning of issue summary.)
- Added
RendererInterface::executeInRenderContext(RenderContext $context, calllable $callable).
Note: 99% of code does not need to change at all.. The only 3 things (besides tests, which often are testing things that only are ever executed within a render context, so those tests need to do so as well) using ::executeInRenderContext():
EarlyRenderingControllerWrapper(newly added)HtmlRenderer(which is doing something very advanced)RestExport(which is also doing something advanced/crazy: using the render system to generate JSON/JSON-LD/HAL/… responses)
Beta phase evaluation
| Issue category | Bug because functionality is broken and metadata is lost. |
|---|---|
| Issue priority | Critical because of security implications of lost cacheability metadata. |
| Prioritized changes | The main goal of this issue is security to help with being able to make more things cacheable. |
| Disruption | Potentially disruptive as e.g. calling \Drupal::l() during some event will throw an Exception - if there is no rendering context. This might be especially happening during cron(). |
Original report
Problem/Motivation
The Render Stack works in the following way:
Anything rendered while the stack is active (e.g. while we are in a root call ) is put on the stack, then merged later, when coming back.
That means any Controller or PageDisplayVariant renderable that uses #pre_render is safe.
However a Controller that uses drupal_render to render something early, which can be as simple as calling \Drupal:l() once its cache context aware, won't have their meta data stored on the stack.
This gives two problems:
a) It makes cacheable CSRF for urls or links more difficult (the same for cache contexts)
b) It leads to a different path when #pre_render is used and when its not used
c) It makes it difficult to automatically send Cache-Tags and Contexts for JSON responses.
Here is a test-case, which I need to make into a real test:
function render_it(&$build, $tag_name = 'late') {
$some_build = [
'#cache' => [
'tags' => [$tag_name],
],
'#markup' => 'M:' . $tag_name,
];
$build['#markup'] = drupal_render($some_build);
}
function c1() {
$build = [];
render_it($build, 'early');
return $build;
}
function c2() {
return [ '#pre_render' => ['render_it'] ];
}
$page['region']['early'] = c1();
$page['region']['late'] = c2();
drupal_render_root($page);
// This is only returning 'late'
print_r($page['#cache']['tags']);
Note: Controllers in general should avoid early rendering. If someone needs to render something early outside of the request, they should use $renderer->renderPlain(), which runs #post_render_cache and has an independent stack, etc.
Proposed resolution
- Introduce a scope into the Renderer service that is defined in two stages:
a) REQUEST: Collect rendered meta data into the $request->getCurrentRequest()->renderMetaData object / array
This is the default and start mode.
b) RENDER: Second, merge back this render data into the main request at the top, so that its processed first - as if it was already on the stack, set the Renderer to be in RENDER mode (mainly to save performance as merging is costly - can be omitted first).
In case for a JSON response we could get the Cache-Tags and Contexts from the $request->renderMetaData and set the appropriate HTTP headers, but that would be follow-up and needs broader discussion.
---
To explain from a different angle, a render stack is active as long as it is called recursively, therefore for the HtmlRender, we have the following Call Stack == Render Stack:
We have the MainContent returned from the Controller.
HtmlRenderer -> Renderer -> render() -> MainContent
Then the meta data and markup is extracted from Main Content and put into Rendered Main Content, which is then placed in the page.
HtmlRenderer -> Renderer -> render() -> Page -> Block 1, Block 2, Block 3, Renderered Main Content
And because the BlockViewBuilder enforces the #pre_render pattern, this is safe.
But for the controller this is different and we end up with N stacks that are created and destroyed:
1. Controller -> Renderer-> render()
2. Controller -> Renderer-> render()
3. Controller -> Renderer-> render()
...
Then we have finally get back the main content, which misses this data:
HtmlRenderer -> Renderer -> render() -> MainContent
However if we store this data from the N stacks and populate it into MainContent before rendering the main content, then the result is the exact same as if the controller was using the #pre_render pattern (which we can't enforce and which would be a DX problem).
And therefore we have consistency again.
And the request object is the perfect place for that, because the controller is tied to the request as well.
And then while sending the response we could get the cache tags and cache contexts from the request as well :).
Remaining tasks
- Create proof of concept
User interface changes
API changes
| Comment | File | Size | Author |
|---|---|---|---|
| #152 | rendered_cache_metadata-2450993-152.patch | 115.4 KB | wim leers |
| #44 | rendered_cache_metadata-2450993-44.patch | 136.22 KB | wim leers |
Comments
Comment #1
fabianx commentedComment #2
fabianx commentedNotes from IRC for myself:
$request→something = $request→something→merge(BM::createFromRenderArray(…))
Comment #3
wim leersAnother example that currently does this:
Searchmodule.SearchController::view->SearchPluginBase->buildResults()->NodeSearch->execute()->NodeSearch->prepareResults()->druapl_render()Comment #4
fabianx commentedI have a new and much simpler idea to solve this:
Have:
- renderRoot() construct the static::$stack
- if render() is called without being in a render context(), throw a helpful Exception - as there is _no_ stack to render on.
- Fix core
Contrib needs to fix itself, but that is good, because currently if you render early without using renderPlain() that is plain unsupported anyway.
--
and the stray renderRoot() call thingy is broken atm. in HEAD also when trying to render an exception within the Renderer. (not sure why though)
Comment #5
fabianx commentedHere is a patch implementing #4.
This is fully compatible with the approach to return a value object from renderPlain() and renderRoot().
We could even think if we want to retire renderPlain() again (after it returns a value object) as now renderPlain() == renderRoot() except for the Exception ...
Lets see how much of core tests break, the unit tests for the Render group are already fixed :).
Comment #7
wim leers#4 sounds excellent. But, it's late here, so perhaps I'm seeing it wrong :)
Comment #8
wim leersRerolled. Now at least the front page and
/node/1can be rendered.Rerolling this also helped me better understand #4 and its consequences. It basically means:
renderPlain()now actually meansrenderInIsolation()(i.e. start its own render stack) — it originally meant "turn this render array into a plain string, and don't care about anything else". But it did that by starting a new stack, and then restoring the old one. So the "getting a plain string" part was only a symptom, what it really is about, is rendering in isolation.renderRoot()is now allowed to start a stack. This is what allows us to guarantee that nothing in Drupal does rendering (i.e. calling::render()) we're not aware of, and therefore whose bubbleable metadata may get lost.::render()can only be called from inside a::renderRoot()or::renderPlain()call. (If that weren't the case, we would still be able to miss some bubbleable metadata, and all in #4 would be flawed.)Three notable changes in this reroll:
HtmlRenderer::renderResponse()no longer calls::render($something, $is_root_call = TRUE), but instead calls::renderRoot($something), because only::renderRoot()sets up a stack, not::render()HtmlRenderer::prepare()still needs to be able to render the main content without executing#post_render_cachecallbacks. So that means calling::renderPlain()/::renderInIsolation(), with$is_root_call = FALSE./node/1didn't work, becauseEntityViewController::view()was calling::render()without a render stack being set up, because it was calling::render()too early.This will still have failures, but less.
Comment #9
wim leersAlso note:
::renderRoot()and::renderPlain()/::renderInIsolation()are now roughly equivalent: they both set up a stack. But only the latter restores the prior one again; the former only resets it back to NULL.We could think about simplifying this, by only keeping one.
Comment #11
wim leersBefore we continue in this direction, which requires fixing all invalid
::render()calls in Drupal core, I'd like to get sign-off from catch or effulgentsia. Assigning to effulgentsia for feedback.Comment #12
fabianx commentedI agree, I will also ping catch. :)
Comment #13
effulgentsia commented+1 to this and fixing all other similar early rendering bugs in core.
+1
-1 to the addition of this parameter. The code that requires passing FALSE seems broken to me. I see the 2 instances of that in HtmlRenderer in this patch so far, and I think we need to fix that class to not require that.
+1, but should we move it to the public render() method instead of here?
Early render() calls are a bug, so +1 to fixing them, and to adding the above exception that requires contrib to fix them too.
Comment #14
effulgentsia commentedAlso, +1 to renaming renderPlain() to renderIsolated() or similar, but I think we should retain renderPlain() as a (deprecated for 9.x) wrapper for BC.
Comment #15
wim leers#post_render_cachecallbacks.Continuing with this patch then, since it's gotten +1s overall.
Comment #16
wim leersFirst fixed this from #4:
Comment #17
wim leersAnd here's a bunch of fixes. It's gonna take quite a bit of work to fix all wrong usages in core.
Comment #20
fabianx commented#15.3:
There is 2 cases where a renderPlain() / renderIsolated() is needed:
- I need a string + some assets for rendering something out-of-bounds, then e.g. sending a remote RPC call, or anything ... I don't care about cacheability, so run all #post_render_cache callbacks.
- I need to render something early due to architecture, but that does not yet mean it will be actually displayed (views fields is such a case, which use $field->theme()).
There it would be helpful if renderInIsolation() would actually return a cacheable render array directly, so that the markup and metadata are preserved and no #post_render_cache callbacks have been run, yet.
Yes, it would be nicer to not render at all, but that is sometimes difficult and that is the use case that $is_root_false = FALSE.
--
Overall +1 to fixing core in this way:
It makes it absolutely simple to get to the next step of allowing cache tags on e.g. CacheableJsonResponse by just grepping for renderRoot(), similar renderPlain() points to early rendering and we can then on a case-by-case basis decide if we need to call #post_render_cache or not.
Which is way way way better than before, where you had to check every single ->render() call.
Comment #21
fabianx commentedInitial review:
When we renderPlain we also need to take into account any metadata that is in $build and add a dependency.
We can do that as a follow-up however, too.
e.g.
Is that not renderRoot as well?
Else we loose the attachments, don't we?
Comment #22
effulgentsia commentedRe: the 2 cases in #20, can we have 2 different methods for them? e.g., renderIsolated() (for rendering an email or whatever) and renderEarly() (for rendering an HTML fragment outside of the proper pipeline, but with the intent to then re-insert the result somewhere into a larger fragment that might be cached). We could at least then document these cases properly, and more easily find any remaining renderEarly() calls to see if we can find a way to fix the use case to get itself back into the proper pipeline.
Comment #23
effulgentsia commentedOr, if renderEarly() doesn't cover it, since maybe there are cases where it's not only about being early, perhaps renderIsolated() and renderIsolatedFragment()?
Comment #24
Crell commentedWim, Fabianx, and I spent most of DrupalCon sprint day discussing this issue. I am going to attempt to capture something vaguely close to what we concluded:
1) Every time you want to render a render array, you MUST have an active render stack context to do so.
2) For the typical cases, the controller and blocks, core will do that for you.
3) For the controller specifically, we can wrap all
_controllerthe calls (much as we used to do with_content, but simpler) with acallablethat will do it for you. However, if the controller result is not an array we throw it away and return the result; at that point you're on your own and we assume you have a view listener that knows what it's doing.4) For the edge cases where you want to render a completely new context (such as an email page, or creating an HTML page other than the one you're about to return, etc.), you have to explicitly create a new context. This is already happening in, eg, contact module.
5) For the edge cases where you have to return a string and pass the metadata back some other way (these are almost all legacy uses of
l()orurl(), or translatable strings), there will be a new method onRendererthat takes acallable(almost always a closure). That method will take the callable's result, render it, return the string, and stick the resulting metadata onto the current stack frame in theRenderer.6) For
UrlGenerator, we can create aUrlRendererservice that wrapsUrlGeneratorand theRenderer. It can take care of the above work so thatUrlGeneratorshouldn't need to be modified. The same can be done forStringTranslation.7) This mechanism should, we think, also resolve the
OutboundPathProcessorproblem. A processor that has cache-context-sensitive logic will need to take theRendereras a dependency and use the method from point 5 itself. If it doesn't, that's a bug. Please file a patch!8) As a delightful side-effect, SafeMarkup's cache can move to the Renderer stack. That means it does not persist beyond a given rendering context. We can therefore render the controller result before calling blocks, or even thinking about blocks, which results in throwing away the string index at that point. It could be built up later by other contexts, but those are independent so they don't need to be the same cache. That reduces the memory footprint of SafeMarkup.
Comment #25
fabianx commentedComment #26
fabianx commentedPromoted this one to critical and demoted #2351015: URL generation does not bubble cache contexts instead.
Comment #27
fabianx commentedThe main justification for this being critical is token handling.
I see no way to support token_replace without having a rendering context / stack active.
Comment #28
effulgentsia commentedWhen is
$token_service->replace()called during controller execution? Isn't it usually called during rendering (e.g., from formatters)?Comment #29
effulgentsia commentedDiscussed this on a call with the other committers, and we decided to defer triaging this as Critical or Major.
There's no question that this would be a great thing to fix, because:
However, 1 and 3 above have other, potentially quicker, solutions in the works, and it's not yet clear if there are any contrib conditions under which 2 or 4 would be critical. So, we'll re-evaluate this one's priority once new information about any of that comes in. Or, if it lands before then, then we won't have to :)
Comment #30
wim leersPer #2429287-102: [meta] Finalize the cache contexts API & DX/usage, enable a leap forward in performance, taking this on to better be able to assess the criticalness, risk and amount of work required.
First, a reroll that applies to HEAD.
Comment #32
wim leers#17 was at 2,090 fail(s), and 372 exception(s).
#30 is at 1,634 fail(s), and 299 exception(s).
Looks like we got a nice reduction "for free" — probably all the other cacheability-fixing patches that went in since helped quite a bit :)
Comment #33
wim leersLess failures.
Comment #35
wim leers#2505835: Optimize CommentAdminOverview should also help reduce the number of failures.
Comment #36
wim leersLess failures.
And one very noteworthy fix:
… note that the fact that
drupal_render()was used in the test meant that some cache tags were missing! Therefore the test was incomplete!(I'd been debugging for an embarrassingly long time, unable to figure out was wrong — turns out the test was wrong!)
Comment #38
wim leersLess failures.
MessageViewBuilderin thecontactmodule./node/add/articleform again, thanks to fixed file + image widgets, as well as#type => actions. I bet this will fix a lot of failures. :)(Note, I tried to get rid of the rendering in
Actions, but to do so, I needed to resolve a@todothat was added by sun back in #1751606: Move published status checkbox next to "Save". The technical debt from the dropbutton is still haunting us, and will continue to do so. So, for now, just kept the exact same logic.)Comment #39
dawehnerUrgs, cheer up!
Minor point: Everytime I do that I add an inline @var statement to make it easier to jump to it, next time you run into it, while debugging
I'm curious, we chang e the places where we throw an exception, I would assume that we also have to update the documention on Renderer itself?
Mh, I'd expected renderPlain here ...
Nice!
Comment #41
wim leers#39: thanks for the early feedback — please note that this patch is still very very very very rough. Yes, the
Rendererwill need updated docs, but the current changes to that class will not be the final changes.hook_help(). Then, yes, absolutely. Done. (Will be in the next patch I post.) Thank you! *Awesome* catch :)Hurray, #38 fixed ~450 failures!
Comment #42
fabianx commentedIIRC, title can now also contain a render array, so possibly using $element['key'] might just work (tm).
Comment #43
andypostis there a way to trow a kind of that exception when something trying to add metadata to stack that does not exists?
Comment #44
wim leers#42: no, it doesn't work, I tried. This is dark dropbutton/theme system + render system interaction magic.
#43: Addressing that is the next step, will be in the next patch! :)
So far, what I've done, is fixing things by looking at test failures, debugging them and then fixing the root cause. But this makes for slow progress, and at some point we'll have to deal with the main purpose of this issue: capturing bubbleable metadata that is lost, because the route's controller is calling
drupal_render()when no stack exists (=== outside of a render context).But, before doing so, and what you'll find in this patch, I looked at all remaining
drupal_render()calls (roughly 250) and determining which ones are broken by design and won't be fixed by the above. This is mostly in tests. Nearly every exception of those 260 exceptions in #38 is caused bydrupal_render()calls in tests. This should fix the majority, and significantly decrease the number of exceptions.Comment #46
wim leers#38:
1,105 fail(s), and 260 exception(s).#44:
1,018 fail(s), and 166 exception(s).So 87 fewer fails, 94 fewer exceptions.
Next step described in #44, i.e. , and that should cause another significant drop in number of test failures. Stay tuned!
Comment #47
andypostAdded 2 more issues under new meta #2509534: [Meta] document or refactor calls to drupal_render() but then found old one and checked all related issues from it #2046881: [meta] Avoid "early rendered" strings where beneficial to do so, build structured data to pass to drupal_render() once instead (they are all already fied)
Comment #48
wim leersRebased.
#2505835: Optimize CommentAdminOverview conflicted (changes here became obsolete, that patch fixed it already) and #1305882: drupal_html_id() considered harmful; remove ajax_html_ids to use GET (not POST) AJAX requests with this, easy fixes, straight reroll.
Comment #49
wim leersI had a 502, bad gateway. That apparently ate the patch. d.o--
Comment #51
wim leersThis patch then finally does what the issue is all about!:
EarlyRenderingControllerWrapperevent subscriber, that wraps the executed controller, thus ensuring that a render context is available. In doing so, it is able to collect the bubbleable metadata that would otherwise be lost. See the docs on that class for a more detailed explanation.RendererInterface::executeInRenderContext()method. This is intended to be used for the most advanced use cases only. Of which (1) is the prime example. It accepts a render stack (yes, stack, not context, because I wanted to keep changes legible — that'll be fixed in the next step, see below), and makes it the current render stack. Once it's done, it restores whatever the current render stack was.Renderer::renderRoot()andRenderer::renderPlain()have been setting up a "render context" all along. But rather than letting them continue to do things directly, now that we have (2), we should unify things, to make things more understandable. So both have been updated to use::executeInRenderContext().::executeInRenderContext()must not reset the stack automatically, otherwise code still can't access the stack, and (1) would be unfixable. But we also don't want to make::resetStack()a public API, that'd make things far too fragile. The solution to that lies in the fact that::resetStack()really only existed as the signal thatRenderer::renderRoot()was using to determine whether it was a nested call. By changing that to use a property on theRendererinstead, I was also able to remove::resetStack().This really only makes things much clearer :)
Next step:
class \Drupal\Core\Render\RenderContext extends \SplStack {…}, that will greatly clarify things (see point 2 above).This should cause another significant drop in the number of failures. But it won't be zero, a whole bunch of bad
drupal_render()calls still exist, those will need to be fixed too, but they'll be easier to surface. I've chosen to keep this reroll focused just on the above, to make it easier to review.(Also rebased again, zero conflicts this time.)
Comment #53
wim leers#49:
1,019 fail(s), and 166 exception(s)#51:
263 fail(s), and 77 exception(s)YAY!
Comment #54
wim leersDone.
Next step: getting to zero failures.
Comment #55
fabianx commented#54 looks like sweet tasty ice-cream :DDD.
Comment #57
wim leersThe increase of failures is due to the move from a class property to an object property. We need to persist the
Rendererservice instead.Comment #59
Crell commentedExciting! I am at a client onsite this week but will try to find time to review. At least from what I see in the comments here I'm smiling.
Comment #60
wim leersTurns out #57 is not the right solution, because…
/facepalm. What happens in #57: the renderer is persisted, so when the test runner creates a test environment, the renderer service is already created before the test environment is set up. So when running the test, the same renderer is still used. Which means a bunch of things don't exist as far as it's concerned, because it was created in a different environment…So, back to a class property (i.e. using
static), the same solution as HEAD.Hopefully that'll bring us back to 263 failures like #51.
P.S.: "Crell smiling commit gate": PASS! :P
Comment #61
wim leersOops, two debug thingies snuck in.
Comment #64
wim leersAnd in the mean time, HEAD changed hugely thanks to #2407195: Move attachment processing to services and per-type response subclasses. Rebased.
Notice that the patch is slightly smaller, thanks to #2407195 :)
Comment #65
wim leersAnd yay, #60 (and hence #64 too, if all is well) is back to 263 failures, just like #51 :)
Now let's fix the remaining failures, most of which should be bad usages of
drupal_render().Comment #66
wim leersThis should fix a whole lot of the failures.
Comment #69
dawehnerJust some quick feedback. I really like it!
One question, could we do all the conversions in tests separately?
So you are not allowed to call drupal_render() during a rest request? That seems quite a hard limitation
Its sad that we need to store state on the renderer. Is there any way we could avoid that? For example, could we look onto the current stack?
Can we keep a BC layer?
I'm curious whether naming it $this->context to $this->contextStack would be a bit easier to understand
It would be great to explain when this is thrown
Comment #70
wim leers#69: Thanks for the feedback! :) Glad that you're generally happy. Yes, we can move the test conversions into separate issues if we want. I'll address your points later, now still working on reducing the number of failures.
About 50 failures less.
Comment #72
fabianx commented#69.1:
There is two mitigating factors:
a) You can easily setup a renderInContext callback yourself.
b) In the future we could opt-out normal Responses from that check - because it is mainly there for Cacheable and Html and Ajax responses, which all have metadata.
Comment #73
wim leers>=29 less failures.
Comment #75
wim leers>=10 fewer exceptions. Painful, repetitive changes to Views' tests, but that's due to the nature of Views' very repetitive/verbose tests (no data provider-style testing), and refactoring those tests is out of scope here. Fortunately, very simple changes.
Nevertheless, that means a whopping 55 KB interdiff.
P.S.: So many changes for only ~10 fewer exceptions? Well yes, but that's because those exceptions didn't actually show the actual impact: once you fixed the test code triggering the first exception, there was another function call triggering the same exception, and so on. Hence the use of the word "repetitive" above.
Comment #76
dawehnerIn other words, the fact how we use simpletest assertions is shit. You should fail immediately, and not run the tests in a sort of broken/running state.
I'm glad that with KernelTestBaseNG for example this will happen.
Comment #78
wim leersAgain fewer exceptions.
Comment #79
wim leersFabianx pointed out that many failures are due to
But… when redirecting, the bubbleable metadata is lost anyway. And it's perfectly valid for the controller to do early rendering, but then in an exceptional case, choose to do a redirect instead. Yet prior patches don't allow that. This explains the failures in Views. By simply only throwing an exception for responses that do care about attachments or cacheability, we remain correct, but fix the failures :)
Thanks, Fabianx!
Comment #82
wim leers#79 already fixed all remaining failures in Views, this interdiff fixes all remaining exceptions in Views, plus all other exceptions. This should bring us down to zero exceptions!
Except…
Drupal\rest\Tests\Views\StyleSerializerTestis locally throwing strange, reproducible PDO errors. I hope that's a local environment problem.Comment #84
wim leersI said "zero exceptions", but I meant "zero yellow test result rows" — there were still 8 exceptions in a test that also has failures.
This brings it down to a single remaining test with failures:
Drupal\simpletest\Tests\SimpleTestTest.Comment #85
wim leersComment #87
wim leersGreen.
Next:
drupal_render()changes that can happen in HEAD into a separate issue/patch, that can be committed much fasterComment #89
wim leersOkay, #2500527: Rewrite \Drupal\file\Controller\FileWidgetAjaxController::upload() to not rely on form cache is the party pooper. Committed three minutes before I posted this. No luck :P
Rebased, resolved a conflict. Let's see if we're still going to be green!
Comment #91
wim leersI resolved the conflict correctly (otherwise we'd have many more failures), but didn't update the newly added test. Will finally be actually green.
Comment #92
wim leers(This does #87.1.)
#13.3: finally fixed that, no more new parameter for
::renderPlain()!#42: tried that. Does not work, it breaks the dropbutton.
#69:
Renderer::renderRoot()Renderer::renderRoot()Renderer::executeInRenderContext(), if the code rendering a REST response is calling out to something else that may calldrupal_render()(this is what Fabian said in #72.a)static::$stackto determine whether we're already rendering something or not. But the way that works in HEAD makes it impossible to fix this issue. This is a simple solution, that actually is also more accurate/semantical! See #51.4 for details.$this->context.Also per our IRC discussion: improved the comment explaining why
Renderer::$contextmust bestatic.Comment #93
wim leers(This does #87.2 and .3.)
Identical to #92, just split up. The "spin-off, do-not-test" patch is also uploaded separately to #2511472: Refactor all usages of drupal_render()/Renderer::render() that break #2450993, which is where it is spun off to. We need to land that ASAP, i.e. before entering reroll hell. Then, the "logical changes" patch here should become green.
Comment #95
wim leersIS updated. Next: CR.
Comment #96
fabianx commentedLittle thing:
Should this not be wrapped in a try {} catch {} as well to restore the render context when exception bubbles up?
Comment #98
wim leers#2511472: Refactor all usages of drupal_render()/Renderer::render() that break #2450993 landed, re-uploading #93's logical changes patch. If all is well, zero failures.
Comment #99
wim leers#96: Why?
Comment #100
fabianx commented#99: Because when the Exception is caught the old render context is active, while in the ideal case it is caught so far outside that no rendering context is active anymore.
It made sense to me that instead of resetStack we just catch all Exceptions, reset our state to the old valid one and re-throw.
I think it could be follow-up though.
Comment #101
wim leers#100:
This is the other try/catch you're referring to.
Note how it is NOT resetting the stack anymore. It is only resetting the
isRenderingRootflag. See #51 for an explanation.Hence it is not necessary to wrap the logic in
::executeInRenderContext()in a try/catch either.Comment #102
fabianx commentedI am giving a tentative RTBC + 1. This patch looks really sweet.
We do have a follow-up already to remove $is_root_call from render(), right? (and then also for doRender).
Comment #103
wim leersYes: #2511330: Deprecate RendererInterface::render()'s sole $is_root_call parameter
Comment #104
Crell commentedI think this is a review of #93... It was sitting in my dreditor for a few days while I went through it across 2 states. :-)
Nit: All of our other subscriber classes have a *Subscriber suffix. Let's follow that pattern.
Assuming we don't want anyone using drupal_render() anymore, just the objects/services, let's not refer to the function in the documentation at all.
It's not lost. It *would have been lost* were it not for this subscriber. Important distinction. I think this paragraph needs to be rewritten accordingly.
The contents of this closure are sufficiently non-trivial that they should be in their own method, and a trivial closure just calls that. Or, even better, push it to its own controller service.
Let's unroll this code block to make the code metrically simpler.
First, we should call the return from the controller $controller_result, to be consistent with elsewhere since it may not be a Response object.
Then, check if $controller_result is an object, of any sort. If it is, assume the controller knew what it was doing. (This is where we can also have the AttachmentsInterface check, etc., if appropriate.) Either return $controller_result or throw an exception.
If it's a string, just return it period. (Or do whatever we do with strings these days; I lost track.)
Then if it's an array, we can go into the other bits of handling here. That means fewer nested conditionals, which makes the code easier to follow and debug.
I'm unclear here on why this is the case. I can't return a Response with attachments? Or anything with attachments? That seems short-sighted to disallow someone to use objects directly and force them into the array form. Why do this? The comment here is unconvincing.
Shouldn't this be "updates the current frame of the stack"?
Bless you for including this.
How does this differ from renderRoot() now, other than renderRoot() having semaphore protection? The actual body of the method is now identical. Does that mean they can be merged?
If render() no longer should be called outside of renderRoot() or renderPlain(), why not make it protected? That would save the check below in doRender().
We haven't moved to 5.5 in code yet, but a finally {} block in renderRoot() seems like the cleaner way to handle this, maybe?
Hm. Are we certain this could only happen due to code-screwups? There's no way for a data-screwup to cause this to happen? (Rough divider: Assertions are for errors that can only possibly ever occur if a developer screwed up the code. If it's possible for data to screw up, it should be an exception.)
Interesting note: This fact is not self-evident from the RenderContext class itself! That should be made clearer.
"Prefer using ::renderRoot()..."
The grammar you have there sounds German, not Belgian. But it's not English either way. :-P
Comment #105
wim leersExcellent review, much appreciated! :) Since they're all either nitpicks or code clarity things, I think you're generally happy with it?
drupal_render(). Which means that if they grep the code base (or api.d.o) for "drupal_render", they'll actually find this. So I think it's a feature to leave this in :)Disagreed, because
$responseis whatHttpKernel::handleRaw()calls it. This is mirrored after that.I wanted to do that too. And the patch used to do that. But that unfortunately doesn't work (as I found out the hard way in #79), because controllers returning render arrays (particularly for rendering forms) may choose to return a
RedirectResponseat any time.Therefore, I think the current logic actually makes as much sense as it can. If you still have suggestions after my above explanations for why things are the way they are in the patch, let me know :)
renderRoot()indeed has a semaphore to protect it from being called while within an existingrenderRoot()call. There are many opportunities here for simplification/clean-up (see #8, #103, etc.), but let us please not do that here, and focus on the task at hand: not losing bubbleable metadata when doing early rendering.render()protected. This is what calling the (deprecated, but still around until D9)drupal_render()calls. It's also what Twig templates when rendering render arrays call.render(), notrenderRoot(). I do have plenty of other places wherefinallywould be most helpful though :)Comment #106
fabianx commentedQuick reminder on #100 :).
Comment #107
Crell commentedYes, I'm on board in large strokes.
The legal return values from controllers gets very tricky with this patch, why I'm concerned. Here's the possible values, and my understanding of what we (I) want them to be. Please fill in (copy and paste to a new comment) what you think they should be and we can then make sure the code handles it accordingly. (With tests, ideally.)
Looking that over, it seems to me that, if I understand this patch's intent (which is to ensure that metadata that gets triggered from within the controller call is captured, no matter what weirdness the controller or its subcalls did), then we can/should go all the way with it. That is:
1) If the return is a render array or AttachmentInterface object, take any captured "global" context and merge it, then return the result.
2) If the return is something very basic like a redirect, pass it through normally.
3) If it's anything else, capture the metadata, produce a render array or AttachmentInterface object, and return that.
Wim, is your understanding that far off from that?
Comment #108
wim leersFirst of all: thanks for asking for test coverage, that was glaringly missing! :O #ashamed
Second: I don't think the legal return values for controllers don't become very complex, in fact… they don't change at all. Nevertheless, that table is a good initiative :)
Responseobjects. Drupal does too, and also allows render arrays. Supporting strings — which surely is possible, and more possible than before thanks to this patch — is very much out of scope for this issue. If you want to introduce that, please file a separate issue for that.HttpFoundation\Responseor subclass, which does not care about bubbleable metadataAttachmentsInterfacenorCacheableDependencyInterfacedon't care about attachments nor cacheability. Hence it's safe to ignore bubbleable metadata from early rendering that is lost.HttpFoundation\Responseor subclass, which does care about bubbleable metadataResponseobject wants to strictly control what is sent to the user. That's why they return aResponsein the first place: tight, complete, final control over what is sent. We don't want to automatically modify these objects. If they want control, then they should also be responsible and render things correctly, and not rely on early rendering being fixed automagically.(Found one bug in the process!)
Comment #109
wim leersChange record created: https://www.drupal.org/node/2513810.
Comment #111
fabianx commented+1000 to #108, that is exactly what we want.
While theoretically we could indeed go to the lengths and merge the data for those responses also - as we have the means to add the data, we want to slowly transition to disallowing early rendering all together, which would be a way too big BC break at this point.
However for the newly introduced interfaces we can do so - so this is a step into a better future ... (*majestic music playing*)
Comment #112
fabianx commentedStill RTBC from my side.
Comment #113
Crell commentedWell, #108 is not directly comparable to #107 since it has different rows and columns. :-) Nonetheless, this is a good discussion tool and the table should eventually get captured into the code structure and comments.
To make it explicit, can you define "Early rendering" for this situation? We keep using the word but I am not sure anyone but Wim or FabianX fully grok its meaning anymore. :-)
Primitives: Yes, I know that's out of scope here. I mention it for completeness. :-) However, IIRC Symfony fullstack does allow strings; At least I know Silex does, and just tosses them into a Response object.
Render array: Looks like we're all on the same page here. Yay.
Response: Sounds like we're in agreement.
Non-Response Object: #108 didn't cover this one. Do you agree/disagree with #107 (ie, return verbatim the same as Response.)
Object implementing AttachmentInterface: Here's where I think we disagree. It seems entirely reasonable to me for a controller to want to control the whole response, or to return a domain object for a View listener to render, and still opt-in to our caching system. Consider the case of a controller that renders a ViewModel object that depends on 2 nodes. It should be cache tagged with both of those nodes. However, turning that ViewModel into a Response is the job of a View listener. But I don't want to also have to do whatever it is to ensure that the cache tags end up in the Response HTTP headers so that it clears properly in the "page" (response) cache or Varnish. I should still be able to feed it declarative information and have the system take care of that for me.
Response implementing AttachmentInterface: This is a narrower use case, but I can still see it being valid for the same reason as above.
One case to keep in mind is that I am quite sure someone is going to push for adding an OOP Render API during the D8 cycle, possibly even the one that Carl has been working on outside of Drupal. We should allow for such more advanced cases to tie into the cache system, which allows for more contrib flexibility.
Put another way: We don't want to tightly couple advanced caching to render arrays if we don't have to. Looking at the code, I'm not convinced we have to, at least as far as controllers are concerned.
I also see a potential security issue here. Understanding render arrays and cache contexts and such is a non-trivial case. I can easily see someone writing code that happens to step on something that needs a cache context (like a url rewrite, which would affect anyone and everyone silently), yet they're returning a ViewModel object for some very good reason. If that happens... poof, you have a cache poisoning hole. Although I suppose that is more a case for merging in caught metadata for all return values, period, just in case.
Comment #114
wim leersBecause I forgot about the objects, you didn't sufficiently distinguish between different types of responses, and I felt another column helps clarify things. Sorry :(
Rendering before a
MainContentRendererimplementation renders it, or more generally speaking: rendering that happens outside of a render context (i.e. norrenderRoot(), norrenderPlain(), norexecuteInRenderContext()is a parent on the call stack).HttpKernelsure doesn't allow it. Let's leave this for a separate issue.Responseobjects at this point, that quite literally, this did not even cross my mind while working on this.)drupal_render()in controllers (D7's page callbacks). This issue is primarily about resolving that, and the potential security problems there. Yes, it does provide the infrastructure to safeguard other return values of controllers (responses and objects) too. But if your controller is returning not a (silly) render array, but a proper Response object, or a domain object… then we also expect you to be able to generate those Response or domain objects responsibly. If you Do The Right Thing and return a Response or a domain object, then we also expect you to do The Right Thing rendering-wise.drupal_render()) — which is a pretty strange/crazy combination to be honest. But we can then continue to discuss in a major follow-up issue if we want to bring some of the render array-returning controller behavior over to those object-returning controllers too. I fear that it's quite bikesheddable. But the beauty is that it's a pure API addition, so we can add it at any point in the future. I want to move on to more important issues than this, because what you're asking for is a pure feature addition IMO.CacheableResponseInterface, and notCacheableDomainObjectInterface. So we'd need to change/add something there, which is yet more to discuss.tl;dr: such handling of early rendering for Response/domain objects is 1) a feature request, 2) not even desirable
This is not at all about advanced caching. Anything can already do that. Especially since #2407195: Move attachment processing to services and per-type response subclasses sets a great example for other
Responsesubclasses/types to follow.This is about dealing with the stupidity of render arrays, and ensuring we aren't insecure because of that.
If you're clever enough to deal with your own domain objects, then you're also smart enough to either mark the corresponding responses as non-cacheable, or to have your domain objects implement
CacheableDependencyInterface.Conclusion: IMHO you're asking for a feature that's out of scope here. Yes, it totally makes sense to bring it up here — it's in scope in that regard — but explicitly designing to have
drupal_render()calls performed by a controller that returnsResponseor domain objects not have their cacheability metadata lost feels like a step in the wrong direction: we want people to stop using early rendering, not for people that HAVE left the render array-centric world view to explicitly rely on such magic behavior.Let me end by quoting the @todo on
\Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber:So, updated table, where I just replaced "Response" with object:
Responseobjects. Drupal does too, and also allows render arrays. Supporting strings — which surely is possible, and more possible than before thanks to this patch — is very much out of scope for this issue. If you want to introduce that, please file a separate issue for that.HttpFoundation\Response(or subclass) or domain object, which does not care about bubbleable metadataAttachmentsInterfacenorCacheableDependencyInterfacedon't care about attachments nor cacheability. Hence it's safe to ignore bubbleable metadata from early rendering that is lost.HttpFoundation\Response(or subclass) or domain object, which does care about bubbleable metadataResponseor domain object wants to strictly control what is sent to the user. That's why they return aResponseor domain object in the first place: tight, complete, final control over what is sent. We don't want to automatically modify these objects. If they want control, then they should also be responsible and render things correctly, and not rely on early rendering being fixed automagically.Comment #115
fabianx commented#113: You miss a tiny detail: We only throw an Exception (for cacheable or attachments included Responses) when the renderContext is not-empty. Any such advanced controller can easily do:
or use #pre_render or use renderRoot() or use ...
So getting that Exception is not the end of the world, it just means you need to "catch" the render context yourself.
We also allow all responses or domain objects unchanged as we say:
- You returned a Response, you know what you are doing. In another follow-up we even (as discussed) want to opt-in only CacheableResponseInterface Responses to automatically gets Drupal's caching headers.
Then we also say, if you return a pure Response, you are also responsible for making it cacheable by setting the right HTTP headers.
But as Wim said: That is still a follow-up.
Comment #116
fabianx commentedI am tentatively setting to RTBC. What Crell is asking for is clearly on the roadmap, but out-of-scope for this issue.
We can also re-visit allowing Cacheable or AttachmentsInterface responses in the future without an API / BC break, while the opposite is not possible.
Therefore and after very careful re-review, setting to RTBC.
Comment #117
alexpottHere's a few nits - overall looks great and makes me think that we should have a followup meta to try to remove as much early rendering as possible.
Not used
Seems a bit long and repetitive. Specifically paragraph 3 and 5. Also "Disaster averted." is funny but not consistent with the normal tone of core docs.
Misspelt method name
Not used
Not used.
Not used
Double ;;
Comment #118
wim leers#117: all fixed.
We have that already: #2509534: [Meta] document or refactor calls to drupal_render().
Comment #119
Crell commentedThis directly affects the request processing system and I still have outstanding concerns. Please don't RTBC it yet. (It's been less than 24 hours since the last response to me.)
Comment #120
andypostAlso change record need update for code examples - at least suggested way to use render context
Comment #121
wim leers#120: 99.9% of code does not need to change. That's the whole point of this issue: we wrap controllers so that not every controller has to do the perfectly correct thing to still be secure. Only a tiny fraction of code needs to use
::executeInRenderContext(). IMO the developers writing the code that needs that are capable of finding it in D8 core's codebase. An example in the CR is going to cause unnecessary concerns with developers reading it.Comment #122
fabianx commentedOfficially tagging and assigning to Crell for sign-off.
It looked like feedback had been addressed. We now have at least the alexpott round behind us :).
Also tagging blocker as this blocks #2351015: URL generation does not bubble cache contexts (that issue can base on this issue, but sooner or later it will be blocked on this one).
@Crell:
I personally would be okay to merge information into (cacheable + attachments) responses, too - if we can - and move the whole do-exception-on-early-render to D9 - if that is what is needed to get this in.
Comment #123
wim leers-1
The summary of this patch:
The quoted part means that controllers returning
Responseobjects — which did not exist in D7, so there is no actual transition easing there — would be allowed to be equally messy as render arrays. We want to get rid of the messiness of render arrays, not have more of it!Comment #124
Crell commentedAmen, reverend. :-P
Let me try and articulate why I'm still a bit uncomfortable here.
1) I understand the desire to avoid scope creep. However, prior to this patch controllers returned directly to the kernel, meaning a view listener could catch anything. We only had listeners in place to support some things, but technically any return could get handled by any view listener you wanted. With this patch, we're black listing some return values. That makes it an effective API change, albeit an edge-case one, and it means we need to be very deliberate about what we white/black list as a legal controller return.
2) Early rendering: Any turning of a render array into a string other than in the controller wrapper or (later) the block wrappers or the final view listener that produces the page.
The reason early rendering is dangerous is because we risk losing either assets or cache contexts along the way. When something is stringified, we need to capture all assets or cache contexts in order to ensure that A) No assets get left out if that portion of the page is loaded from cache and B) No cache contexts get missed, leading to inadvertent cache poisoning. A given render array that has neither assets or cache contexts wouldn't cause an issue. (Not that we want to encourage it, but it wouldn't actually break anything.)
So the real issue is capturing all assets and cache contexts that happen during a controller. Early rendering is just one way for those to happen. However, as Wim, effulgentsia, and I were discussing at the DC LA pre-sprint, those can come from all kinds of seriously weird places. We can say "if you're returning a non-render-array then we assume you're not screwing it up", but it could be something other than your code that is screwing it up. Eg, we were discussing outbound path processors that would affect *every* URL generated... even one for a RedirectResponse. That is, if your controller does nothing but return a RedirectResponse, you could still have a relevant cache context needed.
3) Now, we've said previously that as of CacheableResponseInterface, our cache system is opt-in. That allows someone to return a Response without that interface to take complete control and not get screwed up by our intricate caching system. That's good, but this patch explicitly blacklists CacheableResponseInterface as a legal controller result... that is, it makes it impossible for a controller to opt-in to the system we just made opt-in!
Quoting from #114:
With this patch in its current state, if you're returning not-a-render-array, I couldn't even tell you how I would Do The Right Thing(tm). The options appear to be:
A) Do nothing and opt-out of caching entirely (frequently undesirable)
B) Set all of the appropriate cache headers yourself on a Response, but that would still not include any cache tags unless you did it all manually. (Tip: No one is going to do that.)
C) Early-render your ViewModel to a render array so that it gets past the checks, but now you're doing exactly what we told you not to do. Also, render arrays are *only* for HTML output. If you want to return anything else, you have to return your own response or a ViewModel, and of the two the ViewModel is the superior approach in most cases (IMO).
There is no option for "return a ViewModel that opts-in to caching". To give a concrete example, suppose I've a controller that renders a HAL response using not the Serializer but the Nocarrier/Hal library (a fairly good PHP library for HAL handling that I've used before and quite like). There are plenty of reasons to do that, and return an instance of a Hal object. Of course, the data that's put into that Hal object could come from 2 nodes and a user, and thus needs appropriate cache tags to ensure it gets cleared appropriately. How can I opt-in to the cache system? As written, I can neither render Hal to a Response myself (CacheableResponseInterface is blacklisted) nor subclass Hal to include tags. Too, we explicitly discard any uncaught cache information that may have leaked out, so I can't even rely on the system to help me. Basically, such a module is SOL.
tl;dr:
1) The patch assumes that "leaked metadata" only happens when someone is returning a render array *and* early-rendered something. I do not believe that is a safe assumption, which may lead to security issues.
2) The patch gives no way to opt-in to tag-based caching other than returning a render array, which is only for HTML pages. You can't even opt-in by returning a Response object with the appropriate interface, which you could before. That makes it a regression.
3) As Wim and I have both noted above, render arrays are messy and we want to move to something more structured in the future. Contrib can and should be able to experiment with that, either with generic ViewModels (eg, Carl's OOP Render API) or domain-specific ViewModels (eg, Nocarrier/Hal, HtmlFragment TNG, or whatever else). As written, this patch disallows such experimentation, or at least give such experiments no apparent way to produce safely-cacheable responses. That too is a regression, albeit a more implicit one.
As a bare minimum, I would remove the blacklisting of AttachmentInterface/CacheableResponseInterface. I believe that is counter-productive and a regression. We should not blacklist any controller result options unless we're certain that they are a security hole.
Second, we should check for uncaught metadata on all controller results, not just arrays. That said, I am not sure how to generically handle any metadata we find on arbitrary domain objects. That at least is not a regression, so solving that here is not a blocker, but at the very least we should document that fact and open a (non-critical) follow-up to look into it. Responses, though, we should try to handle if at all possible as that's a known quantity.
Comment #125
Crell commentedGrammar problem. I think the comma in "hence tracked, without a render..." is supposed to be a period. Otherwise it's a run-on sentence.
Comment #126
larowlanThis patch looks really solid. Thanks Wim for answering my questions on irc.
My main observation is this marks one of the few instances where we've consciously decided to babysit broken code.
Comment #127
wim leersFirst off: thank you very much for your very thorough review! :)
I completely agree we need to be very careful.
But I think we are, because the blacklisting only activates if early rendering happened. If early rendering happened, it is guaranteed that that was in fact masking a bug.
(Emphasis in the quote is mine.)
We don't assume you're not screwing up, we disallow (and hence prevent) you from screwing up. We detect early rendering for all controllers (returning anything), but we only fix things automatically if the return value was a render array.
This is why we're making sure in #2351015: URL generation does not bubble cache contexts that we're capturing all cacheability metadata of URL generation — even that of early rendering.
Indeed. But a
RedirectResponsedoes not implementCacheableResponseInterface, which means we can conclude this Response will not be cacheable. If/onceRedirectResponseimplementedCacheableResponseInterface(or we'd add aCacheableRedirectResponseclass), then we'd throw an exception in that case too.This is incorrect.
This patch does not blacklist
CacheableResponseInterfaceat all. All it does, is not allow ("blacklist") aCacheableResponseInterfacevalue to be returned if early rendering happened. Because if early rendering happened, then we know for a fact that the cacheability metadata on this response is in fact wrong (i.e. incomplete). The controller returning theCacheableResponseInterfacevalue should either stop doing early rendering, which would ensure the cacheability metadata is complete, or it should wrap its early rendering in aRendererInterface::executeInRenderContext()call. Then it would have caught the cacheability metadata of its own early rendering, and things would be safe again, andif (!$context->isEmpty()) {in our wrapping subscriber would evaluate to FALSE.Suspected misunderstanding :)
At this point, I suspect you maybe misread the definition of "early rendering": early rendering is the calling of
drupal_render()/RendererInterface::render()when no render context is active. For clarity:RendererInterface::render(Root|Plain)(), you're good. (This is already the case in HEAD.)drupal_render()/RendererInterface::render()WITH somewhere up in the PHP call stack there being aRendererInterface::executeInRenderContext()call, you're good. Because rendering is happening INSIDE of a render context.drupal_render()/RendererInterface::render()WITHOUT somewhere up in the PHP call stack there being aRendererInterface::executeInRenderContext()call, you're NOT good. Because rendering is happening OUTSIDE of a render context.If that's indeed the cause for the misunderstanding, let's document that more clearly. I've already made clarifications in this reroll.
(This is why all of the wrapping subscriber's blacklisting logic is wrapped in that
if (!$context->isEmpty()) {call.)(First, for clarity, repeating your definition of "ViewModel" from #113: .)
A) Indeed.
B) Indeed.
C) No. There are no checks to get past. You clearly want your ViewModel object to carry along the associated cacheability metadata. So:
class ViewModel implements CacheableDependencyInterface. It'd then be up to your view subscriber to take that ViewModel object and turn it into a response (and that would likely be aCacheableResponseInterfaceresponse, but not necessarily.) But I think your key concern is that as part of generating that ViewModel object, a URL is generated (e.g.\Drupal::url('some_route')) and/or some rendering happens (e.g.drupal_render(entity_view(Node::load(5), 'full'))), and that because of that early rendering, the ViewModel object would be blacklisted by the wrapping subscriber. The answer is simple: if you're doing that, then wrap it in a render context, and you'll be fine. Our wrapping subscriber merely helps prevent such bugs!This is not true, either use
renderRoot()/renderPlain(), which render things in a render context automatically, or wrap it in aexecuteInRenderContext()call.(This further convinces me that the "Suspected misunderstanding" section is indeed the misunderstanding.)
This is wrong twice: A) we always check for leaked metadata, regardless of the controller's return value, B) leaked metadata does only happening when doing early rendering. We can detect it with complete certainty. (When combined with #2351015: URL generation does not bubble cache contexts, which also captures URL/link cacheability metadata that is being leaked.)
I completely agree that if those assumptions were in fact the case, that we'd have security issues.
This is also wrong twice:
A) this patch does not change anything about opting in to tag-based caching. Both before and after this patch, it is
FinishResponseSubscriberthat checks if the response is an instance ofCacheableResponseInterfaceand if so, generates the headers for you based on the cacheability metadata in the Response objectB) your controller can return either a
CacheableResponseInterfaceResponse directly (which would opt in to tag-based caching) or any domain object (e.g. ViewModel), and it's then up to the corresponding view subscriber to turn the domain object into aCacheableResponseInterfaceResponse. The domain object may or may not implementCacheableDependencyInterfaceto send along cacheability metadata. Perhaps it's in the view subscriber that all the cacheability metadata comes into play — the domain object could very well be sufficiently independent that it doesn't need cacheability metadata. In other words: you have full freedom; you're not at all restricted to render arraysC) Therefore, there is no regression, because frankly, none of this statement made sense.
I think Crell actually wanted to say "the patch gives no way in to opt in controller return values other than render arrays to have early rendering metadata merged in automatically". That statement would've made sense. So I'll answer that too. But in doing so, I have to basically repeat everything I said in #114.
AttachmentsInterfaceorCacheableResponseInterfaceto automatically have the leaked bubbleable metadata merged as well. That conflicts with the above.AttachmentsInterfaceorCacheableResponseInterfacesince they have setters, it's impossible forCacheableDependencyInterface, which your domain objects (e.g. ViewModel) would be using.Per all of the above, this is incorrect.
While possible, it comes with caveats (see the ordered list with 5 points just a bit higher). You're literally asking for a non-explicit, automagical solution to babysit broken code that is newly written for Drupal 8! Color me confused :)
Again, this is what we already do. See
if (!$context->isEmpty()) {.In this reroll:
EarlyRenderingControllerWrapperSubscriberclass documentation clarified.EarlyRenderingControllerWrapperSubscriber::wrapControllerExecutionInRenderContext()documentation clarified further, added an explicitis_object()check and now also checking forCacheableDependencyInterface(which may be implemented by domain objects).Comment #128
wim leers#126: Thanks for the review, @larowlan!
Indeed: — and my concern is that by doing what Crell is asking for, we're choosing to babysit even more broken code.
The current patch is IMO reasonably babysitting: controllers returning render arrays are encouraged to not do any rendering. That's taken care of for them. If they do early rendering anyway, that causes problems. Historically, we have a lot of that code. So this patch makes the transition easier to D8.
But if we also do what Crell is asking for, then we go beyond what's IMHO reasonable: there is no reason why controllers returning Response or domain objects should be doing early rendering — and note again that if they do
renderRoot()orrenderPlain(), they are NOT doing early rendering.This makes me wonder if this is just a naming problem: what if we rename "early rendering" to "contextless rendering"? (Then:
EarlyRenderingControllerWrapperSubscriber->ContextlessRenderingControllerWrapperSubscriber.)Comment #129
fabianx commentedThe is_object is not needed.
An Exception is too much here, after carefully re-reading Crell's comment I agree that disallowing a case that was allowed before is a regression.
Because at least for AttachmentsInterface and CacheableResponseInterface we can safely merge in the data.
Even for CacheableDependencyInterface we could still think about it.
I think a trigger_error() with WARNING level would be the right thing to do:
- The developers gets to fix the problem. => good!
- On production no error is shown suddenly, because some obscure code path runs into early rendering.
Comment #130
wim leersOops. Fixed.
#130: I disagree with all of that. Again, this means literally babysitting broken code, and violating the "
Responseobjects contain everything to send a HTTP response" principle.No, this we absolutely cannot do. It's designed to have getters only. It explicitly cannot have setters. Otherwise anybody can go and overwrite the cacheability of all sorts of things. Then all the cacheability work we've done is effectively a house of cards.
Comment #131
dawehnerYeah I agree with crell, this discourages a better future.
It would be great to have a todo to get rid of the static ...
I try to understand that change. The style plugin is the serializer, which does not expose any kind of bubbleable metadata ... so this change is kinda odd.
All this test code looks pretty much like it could live happily in a kernel test, which would actually add an additional level of test coverage. It ensures that the http kernel + renderer works for itself, and don't produce any state which is not reset.
Comment #132
wim leersComment #133
Crell commentedWim talked me through the details of the wrapper and rendering on Skype the other day. To clarify for anyone else that had similar concerns (or has them in the future and finds this issue):
1) All of our fancy caching is opt-in, via one of a couple of interfaces. (CacheableResponseInterface and CacheableDependencyInterface, specifically.)
2) If you don't use one of those, it doesn't matter if any cache metadata leaks because it won't get used anyway.
3) If you do use one of those, it means you are providing all of the necessary metadata yourself. If something still leaked, then you have a bug.
4) If you only ever use renderRoot() or renderPlain(), nothing should leak *except* for OutboundUrlProcessors. However, that already has a critical: #2351015: URL generation does not bubble cache contexts
5) According to Wim, there is no other way to leak data beyond that existing critical or using render() yourself. If you do the latter, you're wrong and you should fix your code. This is the part that I was most nervous about. I am going to take Wim's word for it that there is no other possible way to leak metadata. If it turns out there is, that's probably a critical in its own right as well.
6) If you opt-in with one of those interfaces, and nothing leaks, everything works fine and you can return anything.
7) Leaky metadata with a render array return is allowed, because everyone's doing it.
(I'm sure someone is going to say "I just said that in an earlier comment!" Well, it's a complicated issue and the documentation is imperfect, because it's a complicated issue. Ask Wim how many times it took for him to explain it before I finally caught on. :-P)
Therefore, I'm now on board with this issue. My only remaining pushback would be to improve the LogicException message, as even knowing the above now the message is still not helpful. I would suggest instead "The controller result claims to be providing relevant cache metadata, but leaked metadata was detected. Please ensure you are not rendering content too early."
That provides better guidance on what the developer is doing wrong and what to do about it, and documents for future stubborn-minded folk (like me) the limited case in which that exception would actually happen. :-)
Modulo that fix, this has my +1.
Wim++
Comment #134
dawehnerRegarding #131 I opened up a follow up: #2525200: Move EarlyRenderingControllerTest to a KernelTest in order to ensure no state
Comment #135
dawehner#133 in other words, this can be done by wim now again ...
Comment #136
wim leers#133: Thank you — for writing that overview and your great constructive criticism :)
So, per #133, I updated the exception message. But in doing so, I noticed that the
assertResponse()assertions inEarlyRenderingControllerTestwere wrong. I thought the second parameter was asserting the presence of content, but it was just a message to associate with the assertion. Oops. Split them all up intoassertReponse()+assertRaw()assertions. Which made me see that one "Hello world" was not showing due to a small oversight.Comment #137
dawehnerNice!!
Comment #138
plachComment #140
wim leersSo, this is nice, #2309215: HTML double-escaping in revision messages introduced a new early rendering occurrence, and testbot just detected that for us with an exception. :) Fixing it now.
Comment #141
wim leersThis fixes it, but the underlying problem is that Twig's
{% trans %}doesn't support render arrays. We should fix that at #2334319-7: {% trans %} does not support render array and MarkupInterface valued expressions, then all this ugliness goes away :)Comment #142
dawehnerIt is great to find those failures now!
Comment #143
fabianx commentedRTBC + 1, changes make sense.
Comment #145
wim leersI first uploaded a wrong patch, then fixed it. The patch in #141 is correct. I wonder if testbot somehow got the previously uploaded patch? Re-uploading the same patch.
Comment #147
fabianx commented#145: I think that is a different early rendering happening a few lines later :).
Comment #148
wim leersFound it. The early rendering was not happening a few lines later. It was happening in a test.
Comment #149
plachRTBC +1
Comment #150
alexpottWhy are we creating the $column variable?
Lets add an @see to the relevant test.
Our docs standards say that this methods should have docs.
Comment #151
fabianx commented#150.2: Because we want to add cacheable metadata to the column via the Renderer, in this case the username.
The rest should indeed be addressed.
Comment #152
wim leersBecause previously the render array is assigned to
$row[], i.e. appended to the$row, we're not able to add a cacheable dependency to it without first calculating the index of the last added column. This seemed a clearer solution.Comment #153
alexpottThanks for adding the beta evaluation to the issue summary. Committed ebb21d2 and pushed to 8.0.x. Thanks!
Comment #156
yesct commentedthis change record still a draft.
@Wim Leers made it in #109
and
@andypost and @Wim Leers discussed in #120 and #121 if additional examples were needed.
can we publish it?
Comment #157
fabianx commented#156: Thanks for catching that.
I checked and published the change record.
Do we have other lingering draft change records that have a 'fixed' or 'closed fix' issue?
Could someone run a query?
Maybe we should make it a policy that the committer should publish the CRs on fixing the issue?
Or maybe we should make someone else responsible?
Comment #158
yched commentedThis paragraph in the CR is a bit confusing :
"Note that in HEAD, early rendering's bubbleable metadata is lost, which can lead to security problems (due to missing cache tags/contexts on the response)."
I guess that means "in HEAD before the patch was committed", but that is misleading in a change record that is here to stay :-)
Do we really need that paragraph ? It seems like it was meaningful in the context of the issue / issue summary, but in the CR now that it's fixed ?
Comment #159
fabianx commented#158: Indeed, thanks for catching that:
Fixed:
https://www.drupal.org/node/2513810/revisions/view/8716728/8717156
Also added an example how to setup a render context, even though it should only in seldom cases be needed.