1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
5 #include <memory>
6 
7 #include "base/command_line.h"
8 #include "base/files/file_path.h"
9 #include "base/files/file_util.h"
10 #include "base/files/scoped_temp_dir.h"
11 #include "base/macros.h"
12 #include "base/run_loop.h"
13 #include "base/strings/stringprintf.h"
14 #include "content/browser/child_process_security_policy_impl.h"
15 #include "content/browser/frame_host/frame_tree_node.h"
16 #include "content/browser/frame_host/render_frame_host_impl.h"
17 #include "content/browser/web_contents/web_contents_impl.h"
18 #include "content/common/frame_messages.h"
19 #include "content/public/browser/navigation_entry.h"
20 #include "content/public/browser/navigation_handle.h"
21 #include "content/public/browser/render_frame_host.h"
22 #include "content/public/browser/render_process_host.h"
23 #include "content/public/browser/web_contents.h"
24 #include "content/public/common/content_switches.h"
25 #include "content/public/test/browser_test_utils.h"
26 #include "content/public/test/content_browser_test.h"
27 #include "content/public/test/content_browser_test_utils.h"
28 #include "content/public/test/navigation_handle_observer.h"
29 #include "content/public/test/test_navigation_observer.h"
30 #include "content/shell/browser/shell.h"
31 #include "content/shell/browser/shell_content_browser_client.h"
32 #include "content/shell/common/shell_switches.h"
33 #include "content/test/content_browser_test_utils_internal.h"
34 #include "net/base/escape.h"
35 #include "net/dns/mock_host_resolver.h"
36 #include "net/test/embedded_test_server/embedded_test_server.h"
37 #include "net/url_request/url_request.h"
38 #include "net/url_request/url_request_status.h"
39 #include "testing/gmock/include/gmock/gmock-matchers.h"
40 #include "url/gurl.h"
41 
42 namespace content {
43 
44 // WebContentsDelegate that fails to open a URL when there's a request that
45 // needs to be transferred between renderers.
46 class NoTransferRequestDelegate : public WebContentsDelegate {
47  public:
NoTransferRequestDelegate()48   NoTransferRequestDelegate() {}
49 
ShouldTransferNavigation(bool is_main_frame_navigation)50   bool ShouldTransferNavigation(bool is_main_frame_navigation) override {
51     // Intentionally cancel the transfer.
52     return false;
53   }
54 
55  private:
56   DISALLOW_COPY_AND_ASSIGN(NoTransferRequestDelegate);
57 };
58 
59 class CrossSiteTransferTest : public ContentBrowserTest {
60  public:
CrossSiteTransferTest()61   CrossSiteTransferTest() {}
62 
63   // ContentBrowserTest implementation:
SetUpOnMainThread()64   void SetUpOnMainThread() override {
65     host_resolver()->AddRule("*", "127.0.0.1");
66     content::SetupCrossSiteRedirector(embedded_test_server());
67     ASSERT_TRUE(embedded_test_server()->Start());
68   }
69 
70  protected:
NavigateToURLContentInitiated(Shell * window,const GURL & url,bool should_replace_current_entry,bool should_wait_for_navigation)71   void NavigateToURLContentInitiated(Shell* window,
72                                      const GURL& url,
73                                      bool should_replace_current_entry,
74                                      bool should_wait_for_navigation) {
75     std::unique_ptr<TestNavigationManager> navigation_manager =
76         should_wait_for_navigation
77             ? std::unique_ptr<TestNavigationManager>(
78                   new TestNavigationManager(window->web_contents(), url))
79             : nullptr;
80     std::string script;
81     if (should_replace_current_entry)
82       script = base::StringPrintf("location.replace('%s')", url.spec().c_str());
83     else
84       script = base::StringPrintf("location.href = '%s'", url.spec().c_str());
85     bool result = ExecuteScript(window, script);
86     EXPECT_TRUE(result);
87     if (should_wait_for_navigation) {
88       EXPECT_TRUE(navigation_manager->WaitForRequestStart());
89       EXPECT_TRUE(navigation_manager->WaitForResponse());
90       navigation_manager->WaitForNavigationFinished();
91       EXPECT_TRUE(navigation_manager->was_successful());
92     }
93   }
94 
SetUpCommandLine(base::CommandLine * command_line)95   void SetUpCommandLine(base::CommandLine* command_line) override {
96     IsolateAllSitesForTesting(command_line);
97   }
98 };
99 
100 // The following tests crash in the ThreadSanitizer runtime,
101 // http://crbug.com/356758.
102 #if defined(THREAD_SANITIZER)
103 #define MAYBE_ReplaceEntryCrossProcessThenTransfer \
104     DISABLED_ReplaceEntryCrossProcessThenTransfer
105 #define MAYBE_ReplaceEntryCrossProcessTwice \
106     DISABLED_ReplaceEntryCrossProcessTwice
107 #else
108 #define MAYBE_ReplaceEntryCrossProcessThenTransfer \
109     ReplaceEntryCrossProcessThenTransfer
110 #define MAYBE_ReplaceEntryCrossProcessTwice ReplaceEntryCrossProcessTwice
111 #endif
112 // Tests that the |should_replace_current_entry| flag persists correctly across
113 // request transfers that began with a cross-process navigation.
IN_PROC_BROWSER_TEST_F(CrossSiteTransferTest,MAYBE_ReplaceEntryCrossProcessThenTransfer)114 IN_PROC_BROWSER_TEST_F(CrossSiteTransferTest,
115                        MAYBE_ReplaceEntryCrossProcessThenTransfer) {
116   NavigationController& controller = shell()->web_contents()->GetController();
117 
118   // Navigate to a starting URL, so there is a history entry to replace.
119   GURL url1 = embedded_test_server()->GetURL("/site_isolation/blank.html?1");
120   EXPECT_TRUE(NavigateToURL(shell(), url1));
121 
122   // Navigate to a page on A.com with entry replacement. This navigation is
123   // cross-site, so the renderer will send it to the browser via OpenURL to give
124   // to a new process. It will then be transferred into yet another process due
125   // to the call above.
126   GURL url2 =
127       embedded_test_server()->GetURL("A.com", "/site_isolation/blank.html?2");
128   NavigateToURLContentInitiated(shell(), url2, true, true);
129 
130   // There should be one history entry. url2 should have replaced url1.
131   EXPECT_TRUE(controller.GetPendingEntry() == nullptr);
132   EXPECT_EQ(1, controller.GetEntryCount());
133   EXPECT_EQ(0, controller.GetCurrentEntryIndex());
134   EXPECT_EQ(url2, controller.GetEntryAtIndex(0)->GetURL());
135 
136   // Now navigate as before to a page on B.com, but normally (without
137   // replacement). This will still perform a double process-swap as above, via
138   // OpenURL and then transfer.
139   GURL url3 =
140       embedded_test_server()->GetURL("B.com", "/site_isolation/blank.html?3");
141   NavigateToURLContentInitiated(shell(), url3, false, true);
142 
143   // There should be two history entries. url2 should have replaced url1. url2
144   // should not have replaced url3.
145   EXPECT_TRUE(controller.GetPendingEntry() == nullptr);
146   EXPECT_EQ(2, controller.GetEntryCount());
147   EXPECT_EQ(1, controller.GetCurrentEntryIndex());
148   EXPECT_EQ(url2, controller.GetEntryAtIndex(0)->GetURL());
149   EXPECT_EQ(url3, controller.GetEntryAtIndex(1)->GetURL());
150 }
151 
152 // Tests that the |should_replace_current_entry| flag persists correctly across
153 // request transfers that began with a content-initiated in-process
154 // navigation. This test is the same as the test above, except transfering from
155 // in-process.
IN_PROC_BROWSER_TEST_F(CrossSiteTransferTest,ReplaceEntryInProcessThenTransfer)156 IN_PROC_BROWSER_TEST_F(CrossSiteTransferTest,
157                        ReplaceEntryInProcessThenTransfer) {
158   NavigationController& controller = shell()->web_contents()->GetController();
159 
160   // Navigate to a starting URL, so there is a history entry to replace.
161   GURL url = embedded_test_server()->GetURL("/site_isolation/blank.html?1");
162   EXPECT_TRUE(NavigateToURL(shell(), url));
163 
164   // Navigate in-process with entry replacement. It will then be transferred
165   // into a new one due to the call above.
166   GURL url2 = embedded_test_server()->GetURL("/site_isolation/blank.html?2");
167   NavigateToURLContentInitiated(shell(), url2, true, true);
168 
169   // There should be one history entry. url2 should have replaced url1.
170   EXPECT_TRUE(controller.GetPendingEntry() == nullptr);
171   EXPECT_EQ(1, controller.GetEntryCount());
172   EXPECT_EQ(0, controller.GetCurrentEntryIndex());
173   EXPECT_EQ(url2, controller.GetEntryAtIndex(0)->GetURL());
174 
175   // Now navigate as before, but without replacement.
176   GURL url3 = embedded_test_server()->GetURL("/site_isolation/blank.html?3");
177   NavigateToURLContentInitiated(shell(), url3, false, true);
178 
179   // There should be two history entries. url2 should have replaced url1. url2
180   // should not have replaced url3.
181   EXPECT_TRUE(controller.GetPendingEntry() == nullptr);
182   EXPECT_EQ(2, controller.GetEntryCount());
183   EXPECT_EQ(1, controller.GetCurrentEntryIndex());
184   EXPECT_EQ(url2, controller.GetEntryAtIndex(0)->GetURL());
185   EXPECT_EQ(url3, controller.GetEntryAtIndex(1)->GetURL());
186 }
187 
188 // Tests that the |should_replace_current_entry| flag persists correctly across
189 // request transfers that cross processes twice from renderer policy.
IN_PROC_BROWSER_TEST_F(CrossSiteTransferTest,MAYBE_ReplaceEntryCrossProcessTwice)190 IN_PROC_BROWSER_TEST_F(CrossSiteTransferTest,
191                        MAYBE_ReplaceEntryCrossProcessTwice) {
192   NavigationController& controller = shell()->web_contents()->GetController();
193 
194   // Navigate to a starting URL, so there is a history entry to replace.
195   GURL url1 = embedded_test_server()->GetURL("/site_isolation/blank.html?1");
196   EXPECT_TRUE(NavigateToURL(shell(), url1));
197 
198   // Navigate to a page on A.com which redirects to B.com with entry
199   // replacement. This will switch processes via OpenURL twice. First to A.com,
200   // and second in response to the server redirect to B.com. The second swap is
201   // also renderer-initiated via OpenURL because decidePolicyForNavigation is
202   // currently applied on redirects.
203   GURL::Replacements replace_host;
204   GURL url2b =
205       embedded_test_server()->GetURL("B.com", "/site_isolation/blank.html?2");
206   GURL url2a = embedded_test_server()->GetURL(
207       "A.com", "/cross-site/" + url2b.host() + url2b.PathForRequest());
208   NavigateToURLContentInitiated(shell(), url2a, true, true);
209 
210   // There should be one history entry. url2b should have replaced url1.
211   EXPECT_TRUE(controller.GetPendingEntry() == nullptr);
212   EXPECT_EQ(1, controller.GetEntryCount());
213   EXPECT_EQ(0, controller.GetCurrentEntryIndex());
214   EXPECT_EQ(url2b, controller.GetEntryAtIndex(0)->GetURL());
215 
216   // Now repeat without replacement.
217   GURL url3b =
218       embedded_test_server()->GetURL("B.com", "/site_isolation/blank.html?3");
219   GURL url3a = embedded_test_server()->GetURL(
220       "A.com", "/cross-site/" + url3b.host() + url3b.PathForRequest());
221   NavigateToURLContentInitiated(shell(), url3a, false, true);
222 
223   // There should be two history entries. url2b should have replaced url1. url3b
224   // should not have replaced url2b.
225   EXPECT_TRUE(controller.GetPendingEntry() == nullptr);
226   EXPECT_EQ(2, controller.GetEntryCount());
227   EXPECT_EQ(1, controller.GetCurrentEntryIndex());
228   EXPECT_EQ(url2b, controller.GetEntryAtIndex(0)->GetURL());
229   EXPECT_EQ(url3b, controller.GetEntryAtIndex(1)->GetURL());
230 }
231 
232 // Tests that the request is destroyed when a cross process navigation is
233 // cancelled.
IN_PROC_BROWSER_TEST_F(CrossSiteTransferTest,NoLeakOnCrossSiteCancel)234 IN_PROC_BROWSER_TEST_F(CrossSiteTransferTest, NoLeakOnCrossSiteCancel) {
235   NavigationController& controller = shell()->web_contents()->GetController();
236 
237   // Navigate to a starting URL, so there is a history entry to replace.
238   GURL url1 = embedded_test_server()->GetURL("/site_isolation/blank.html?1");
239   EXPECT_TRUE(NavigateToURL(shell(), url1));
240 
241   NoTransferRequestDelegate no_transfer_request_delegate;
242   WebContentsDelegate* old_delegate = shell()->web_contents()->GetDelegate();
243   shell()->web_contents()->SetDelegate(&no_transfer_request_delegate);
244 
245   // Navigate to a page on A.com with entry replacement. This navigation is
246   // cross-site, so the renderer will send it to the browser via OpenURL to give
247   // to a new process. It will then be transferred into yet another process due
248   // to the call above.
249   GURL url2 =
250       embedded_test_server()->GetURL("A.com", "/site_isolation/blank.html?2");
251   TestNavigationManager navigation_manager(shell()->web_contents(), url2);
252 
253   NavigationHandleObserver handle_observer(shell()->web_contents(), url2);
254   // Don't wait for the navigation to complete, since that never happens in
255   // this case.
256   NavigateToURLContentInitiated(shell(), url2, false, false);
257 
258   // Make sure the request for url2 did not complete.
259   EXPECT_FALSE(navigation_manager.WaitForResponse());
260 
261   // There should be one history entry, with url1.
262   EXPECT_EQ(1, controller.GetEntryCount());
263   EXPECT_EQ(0, controller.GetCurrentEntryIndex());
264   EXPECT_EQ(url1, controller.GetEntryAtIndex(0)->GetURL());
265 
266   EXPECT_EQ(net::ERR_ABORTED, handle_observer.net_error_code());
267   shell()->web_contents()->SetDelegate(old_delegate);
268 }
269 
270 // Test that verifies that a cross-process transfer retains ability to read
271 // files encapsulated by HTTP POST body that is forwarded to the new renderer.
272 // Invalid handling of this scenario has been suspected as the cause of at least
273 // some of the renderer kills tracked in https://crbug.com/613260.
IN_PROC_BROWSER_TEST_F(CrossSiteTransferTest,PostWithFileData)274 IN_PROC_BROWSER_TEST_F(CrossSiteTransferTest, PostWithFileData) {
275   // Navigate to the page with form that posts via 307 redirection to
276   // |redirect_target_url| (cross-site from |form_url|).  Using 307 (rather than
277   // 302) redirection is important to preserve the HTTP method and POST body.
278   GURL form_url(embedded_test_server()->GetURL(
279       "a.com", "/form_that_posts_cross_site.html"));
280   GURL redirect_target_url(embedded_test_server()->GetURL("x.com", "/echoall"));
281   EXPECT_TRUE(NavigateToURL(shell(), form_url));
282 
283   // Prepare a file to upload.
284   base::ScopedAllowBlockingForTesting allow_blocking;
285   base::ScopedTempDir temp_dir;
286   base::FilePath file_path;
287   std::string file_content("test-file-content");
288   ASSERT_TRUE(temp_dir.CreateUniqueTempDir());
289   ASSERT_TRUE(base::CreateTemporaryFileInDir(temp_dir.GetPath(), &file_path));
290   ASSERT_LT(
291       0, base::WriteFile(file_path, file_content.data(), file_content.size()));
292 
293   base::RunLoop run_loop;
294   // Fill out the form to refer to the test file.
295   std::unique_ptr<FileChooserDelegate> delegate(
296       new FileChooserDelegate(file_path, run_loop.QuitClosure()));
297   shell()->web_contents()->SetDelegate(delegate.get());
298   EXPECT_TRUE(ExecuteScript(shell()->web_contents(),
299                             "document.getElementById('file').click();"));
300   run_loop.Run();
301 
302   // Remember the old process id for a sanity check below.
303   int old_process_id =
304       shell()->web_contents()->GetMainFrame()->GetProcess()->GetID();
305 
306   // Submit the form.
307   TestNavigationObserver form_post_observer(shell()->web_contents(), 1);
308   EXPECT_TRUE(
309       ExecuteScript(shell(), "document.getElementById('file-form').submit();"));
310   form_post_observer.Wait();
311 
312   // Verify that we arrived at the expected, redirected location.
313   EXPECT_EQ(redirect_target_url,
314             shell()->web_contents()->GetLastCommittedURL());
315 
316   // Verify that the test really verifies access of a *new* renderer process.
317   int new_process_id =
318       shell()->web_contents()->GetMainFrame()->GetProcess()->GetID();
319   ASSERT_NE(new_process_id, old_process_id);
320 
321   // MAIN VERIFICATION: Check if the new renderer process is able to read the
322   // file.
323   EXPECT_TRUE(ChildProcessSecurityPolicyImpl::GetInstance()->CanReadFile(
324       new_process_id, file_path));
325 
326   // Verify that POST body got preserved by 307 redirect.  This expectation
327   // comes from: https://tools.ietf.org/html/rfc7231#section-6.4.7
328   std::string actual_page_body;
329   EXPECT_TRUE(ExecuteScriptAndExtractString(
330       shell()->web_contents(),
331       "window.domAutomationController.send("
332       "document.getElementsByTagName('pre')[0].innerText);",
333       &actual_page_body));
334   EXPECT_THAT(actual_page_body, ::testing::HasSubstr(file_content));
335   EXPECT_THAT(actual_page_body,
336               ::testing::HasSubstr(file_path.BaseName().AsUTF8Unsafe()));
337   EXPECT_THAT(actual_page_body,
338               ::testing::HasSubstr("form-data; name=\"file\""));
339 }
340 
341 // Test that verifies that if navigation originator doesn't have access to a
342 // file, then no access is granted after a cross-process transfer of POST data.
343 // This is a regression test for https://crbug.com/726067.
344 //
345 // This test is somewhat similar to
346 // http/tests/navigation/form-targets-cross-site-frame-post.html web test
347 // except that it 1) tests with files, 2) simulates a malicious scenario and 3)
348 // verifies file access (all of these 3 things are not possible with web
349 // tests).
350 //
351 // This test is very similar to CrossSiteTransferTest.PostWithFileData above,
352 // except that it simulates a malicious form / POST originator.
IN_PROC_BROWSER_TEST_F(CrossSiteTransferTest,MaliciousPostWithFileData)353 IN_PROC_BROWSER_TEST_F(CrossSiteTransferTest, MaliciousPostWithFileData) {
354   // The initial test window is a named form target.
355   GURL initial_target_url(
356       embedded_test_server()->GetURL("initial-target.com", "/title1.html"));
357   EXPECT_TRUE(NavigateToURL(shell(), initial_target_url));
358   WebContents* target_contents = shell()->web_contents();
359   EXPECT_TRUE(ExecuteScript(target_contents, "window.name = 'form-target';"));
360 
361   // Create a new window containing a form targeting |target_contents|.
362   GURL form_url(embedded_test_server()->GetURL(
363       "main.com", "/form_that_posts_cross_site.html"));
364   Shell* other_window = OpenPopup(target_contents, form_url, "form-window");
365   WebContents* form_contents = other_window->web_contents();
366   EXPECT_TRUE(ExecuteScript(
367       form_contents,
368       "document.getElementById('file-form').target = 'form-target';"));
369 
370   // Verify the current locations and process placement of |target_contents|
371   // and |form_contents|.
372   EXPECT_EQ(initial_target_url, target_contents->GetLastCommittedURL());
373   EXPECT_EQ(form_url, form_contents->GetLastCommittedURL());
374   EXPECT_NE(target_contents->GetMainFrame()->GetProcess()->GetID(),
375             form_contents->GetMainFrame()->GetProcess()->GetID());
376 
377   // Prepare a file to upload.
378   base::ScopedAllowBlockingForTesting allow_blocking;
379   base::ScopedTempDir temp_dir;
380   base::FilePath file_path;
381   std::string file_content("test-file-content");
382   ASSERT_TRUE(temp_dir.CreateUniqueTempDir());
383   ASSERT_TRUE(base::CreateTemporaryFileInDir(temp_dir.GetPath(), &file_path));
384   ASSERT_LT(
385       0, base::WriteFile(file_path, file_content.data(), file_content.size()));
386 
387   base::RunLoop run_loop;
388   // Fill out the form to refer to the test file.
389   std::unique_ptr<FileChooserDelegate> delegate(
390       new FileChooserDelegate(file_path, run_loop.QuitClosure()));
391   form_contents->Focus();
392   form_contents->SetDelegate(delegate.get());
393   EXPECT_TRUE(
394       ExecuteScript(form_contents, "document.getElementById('file').click();"));
395   run_loop.Run();
396   ChildProcessSecurityPolicyImpl* security_policy =
397       ChildProcessSecurityPolicyImpl::GetInstance();
398   EXPECT_TRUE(security_policy->CanReadFile(
399       form_contents->GetMainFrame()->GetProcess()->GetID(), file_path));
400 
401   // Simulate a malicious situation, where the renderer doesn't really have
402   // access to the file.
403   security_policy->RevokeAllPermissionsForFile(
404       form_contents->GetMainFrame()->GetProcess()->GetID(), file_path);
405   EXPECT_FALSE(security_policy->CanReadFile(
406       form_contents->GetMainFrame()->GetProcess()->GetID(), file_path));
407   EXPECT_FALSE(security_policy->CanReadFile(
408       target_contents->GetMainFrame()->GetProcess()->GetID(), file_path));
409 
410   // Submit the form and wait until the malicious renderer gets killed.
411   RenderProcessHostBadIpcMessageWaiter kill_waiter(
412       form_contents->GetMainFrame()->GetProcess());
413   EXPECT_TRUE(ExecuteScript(
414       form_contents,
415       "setTimeout(\n"
416       "  function() { document.getElementById('file-form').submit(); },\n"
417       "  0);"));
418   EXPECT_EQ(bad_message::ILLEGAL_UPLOAD_PARAMS, kill_waiter.Wait());
419 
420   // The target frame should still be at the original location - the malicious
421   // navigation should have been stopped.
422   EXPECT_EQ(initial_target_url, target_contents->GetLastCommittedURL());
423 
424   // Both processes still shouldn't have access.
425   EXPECT_FALSE(security_policy->CanReadFile(
426       form_contents->GetMainFrame()->GetProcess()->GetID(), file_path));
427   EXPECT_FALSE(security_policy->CanReadFile(
428       target_contents->GetMainFrame()->GetProcess()->GetID(), file_path));
429 }
430 
431 // Regression test for https://crbug.com/538784 -- ensures that one can't
432 // sidestep cross-process navigation by detaching a frame mid-request.
IN_PROC_BROWSER_TEST_F(CrossSiteTransferTest,NoDeliveryToDetachedFrame)433 IN_PROC_BROWSER_TEST_F(CrossSiteTransferTest, NoDeliveryToDetachedFrame) {
434   GURL attacker_page = embedded_test_server()->GetURL(
435       "evil.com", "/cross_site_iframe_factory.html?evil(evil)");
436   EXPECT_TRUE(NavigateToURL(shell(), attacker_page));
437 
438   FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
439                             ->GetFrameTree()
440                             ->root();
441 
442   RenderFrameHost* child_frame = root->child_at(0)->current_frame_host();
443 
444   // Attacker initiates a navigation to a cross-site document. Under --site-per-
445   // process, these bytes must not be sent to the attacker process.
446   GURL target_resource =
447       embedded_test_server()->GetURL("a.com", "/title1.html");
448   TestNavigationManager target_navigation(shell()->web_contents(),
449                                           target_resource);
450   EXPECT_TRUE(ExecuteScript(
451       shell()->web_contents()->GetMainFrame(),
452       base::StringPrintf("document.getElementById('child-0').src='%s'",
453                          target_resource.spec().c_str())));
454 
455   // Wait for the navigation to start.
456   EXPECT_TRUE(target_navigation.WaitForRequestStart());
457   target_navigation.ResumeNavigation();
458 
459   // Inject a frame detach message. An attacker-controlled renderer could do
460   // this without also cancelling the pending navigation (as blink would, if you
461   // removed the iframe from the document via js).
462   child_frame->OnMessageReceived(
463       FrameHostMsg_Detach(child_frame->GetRoutingID()));
464 
465   // This should cancel the navigation.
466   EXPECT_FALSE(target_navigation.WaitForResponse())
467       << "Request should have been cancelled before reaching the renderer.";
468 }
469 
470 }  // namespace content
471