/* * Copyright 2013 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.template.soy.parsepasses.contextautoesc; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import com.google.common.base.Joiner; import com.google.common.collect.ImmutableMap; import com.google.template.soy.SoyFileSet; import com.google.template.soy.SoyFileSetParser.ParseResult; import com.google.template.soy.SoyFileSetParserBuilder; import com.google.template.soy.error.ErrorReporter; import com.google.template.soy.error.ExplodingErrorReporter; import com.google.template.soy.shared.restricted.SoyPrintDirective; import com.google.template.soy.soytree.SoyFileNode; import com.google.template.soy.soytree.SoyFileSetNode; import com.google.template.soy.soytree.TemplateNode; import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; /** * Test for {@link ContentSecurityPolicyPass}. * */ @RunWith(JUnit4.class) public final class ContentSecurityPolicyPassTest { private static final String NONCE = "{if $ij.csp_nonce} nonce=\"{$ij.csp_nonce |filterCspNonceValue}\"{/if}"; @Test public void testTrivialTemplate() { assertInjected( join("{template .foo}\n", "Hello, World!\n", "{/template}"), join("{template .foo}\n", "Hello, World!\n", "{/template}")); } @Test public void testOneScriptWithBody() { assertInjected( join( "{template .foo}\n", "<script" + NONCE + ">alert('Hello, World!')</script>\n", "{/template}"), join("{template .foo}\n", "<script>alert('Hello, World!')</script>\n", "{/template}")); } @Test public void testOneSrcedScript() { assertInjected( join("{template .foo}\n", "<script src=\"app.js\"" + NONCE + "></script>\n", "{/template}"), join("{template .foo}\n", "<script src=\"app.js\"></script>\n", "{/template}")); } @Test public void testManyScripts() { assertInjected( join( "{template .foo}\n", "<script src=\"one.js\"" + NONCE + "></script>", "<script src=two.js" + NONCE + "></script>", "<script src=three.js " + NONCE + "/></script>", "<h1>Not a script</h1>", "<script type='text/javascript'" + NONCE + ">main()</script>\n", "{/template}"), join( "{template .foo}\n", "<script src=\"one.js\"></script>", "<script src=two.js></script>", "<script src=three.js /></script>", "<h1>Not a script</h1>", "<script type='text/javascript'>main()</script>\n", "{/template}")); } @Test public void testFakeScripts() { assertInjected( join( "{template .foo}\n", "<noscript></noscript>", "<script" + NONCE + ">alert('Hi');</script>", "<!-- <script>notAScript()</script> -->", "<textarea><script>notAScript()</script></textarea>", "<script is-script=yes>document.write('<script>not()<\\/script>');</script>", "<a href=\"//google.com/search?q=<script>hi()</script>\">Link</a>\n", "{/template}"), join( "{template .foo}\n", "<noscript></noscript>", // An actual script in a sea of imposters. "<script>alert('Hi');</script>", // Injecting a nonce into something that is not a script might be bad. "<!-- <script>notAScript()</script> -->", "<textarea><script>notAScript()</script></textarea>", "<script is-script=yes>document.write('<script>not()<\\/script>');</script>", "<a href=\"//google.com/search?q=<script>hi()</script>\">Link</a>\n", "{/template}")); } @Test public void testPrintDirectiveInScriptTag() { assertInjected( join( "{template .foo}\n", " {@param appScriptUrl: ?}\n", "<script src=", "'{$appScriptUrl |filterTrustedResourceUri |escapeHtmlAttribute}'", NONCE + ">", "alert('Hello, World!')</script>\n", "{/template}"), join( "{template .foo}\n", " {@param appScriptUrl: ?}\n", "<script src='{$appScriptUrl}'>", "alert('Hello, World!')</script>\n", "{/template}")); } @Test public void testOneStyleTag() { assertInjected( join( "{template .foo}\n", "<style type=text/css", NONCE, ">", "p {lb} color: purple {rb}", "</style>\n", "{/template}"), join( "{template .foo}\n", "<style type=text/css>p {lb} color: purple {rb}</style>\n", "{/template}")); } @Test public void testTrailingSlashes() { assertInjected( join( "{template .foo}\n", "<script src=//example.com/unquoted/url/" + NONCE + "></script>\n", "{/template}"), join( "{template .foo}\n", "<script src=//example.com/unquoted/url/></script>\n", "{/template}")); } @Test public void testInlineEventHandlersAndStyles() { assertInjected( join( "{template .foo}\n", " {@param height: int}\n", "<a href='#' style='", "height:{$height |filterCssValue |escapeHtmlAttribute}px;'", " onclick='", "foo() && bar(\"baz\")'", ">", // Don't bless unquoted attributes since we can't // be confident that they end where they're supposed to, // so aren't sure that we aren't also blessing an // untrusted suffix. "<a href='#' onmouseover=foo()", " style=color:red>", "<input checked ONCHANGE = \"", "Panic()\"", ">", "<script onerror= '", "scriptError()'", "{if $ij.csp_nonce}", " nonce=\"{$ij.csp_nonce |filterCspNonceValue}\"", "{/if}", ">baz()</script>\n", "{/template}"), join( "{template .foo}\n", " {@param height: int}\n", "<a href='#' style='height:{$height}px;' onclick='foo() && bar(\"baz\")'>", "<a href='#' onmouseover=foo() style=color:red>", "<input checked ONCHANGE = \"Panic()\">", "<script onerror= 'scriptError()'>baz()</script>\n", "{/template}")); } // regression test for a bug where an attacker controlled csp_nonce variable could introduce an // XSS because no escaping directives were applied. Generally csp_nonce variables should not be // attacker controlled, but since applications are responsible for configuring them they may in // fact be. So we need to escape them. @Test public void testEscaping_script() { SoyFileSet.Builder builder = SoyFileSet.builder(); builder.add( join( "{namespace ns}\n", "{template .foo}\n", "<script>var innocentJs=\"foo\"</script>\n", "{/template}"), "test.soy"); String renderedValue = builder .build() .compileTemplates() .renderTemplate("ns.foo") .setIj(ImmutableMap.of("csp_nonce", "\">alert('hello')</script><script data-foo=\"")) .render() .get(); assertEquals("<script nonce=\"zSoyz\">var innocentJs=\"foo\"</script>", renderedValue); } @Test public void testEscaping_inline() { SoyFileSet.Builder builder = SoyFileSet.builder(); builder.add( join( "{namespace ns}\n", "{template .foo}\n", "<a href='#' onmouseover='foo()'>click me</a>\n", "{/template}"), "test.soy"); String renderedValue = builder .build() .compileTemplates() .renderTemplate("ns.foo") .setIj(ImmutableMap.of("csp_nonce", "*/alert('hello');/*")) .render() .get(); // We don't inject into inline event handlers anymore assertEquals("<a href='#' onmouseover='foo()'>click me</a>", renderedValue); } private static String join(String... lines) { return Joiner.on("").join(lines); } private static SoyFileSetNode parseAndApplyCspPass(String input) { String namespace = "{namespace ns autoescape=\"deprecated-contextual\"}\n\n"; ErrorReporter boom = ExplodingErrorReporter.get(); ParseResult parseResult = SoyFileSetParserBuilder.forFileContents(namespace + input).errorReporter(boom).parse(); ContextualAutoescaper contextualAutoescaper = new ContextualAutoescaper(ImmutableMap.<String, SoyPrintDirective>of()); List<TemplateNode> extras = contextualAutoescaper.rewrite(parseResult.fileSet(), parseResult.registry(), boom); SoyFileNode file = parseResult.fileSet().getChild(parseResult.fileSet().numChildren() - 1); file.addChildren(file.numChildren(), extras); ContentSecurityPolicyPass.blessAuthorSpecifiedScripts( contextualAutoescaper.getSlicedRawTextNodes()); return parseResult.fileSet(); } /** * Returns the contextually rewritten and injected source. * * <p>The Soy tree may have multiple files, but only the source code for the first is returned. */ private static void assertInjected(String expectedOutput, String input) { SoyFileSetNode soyTree = parseAndApplyCspPass(input); StringBuilder src = new StringBuilder(); src.append(soyTree.getChild(0).toSourceString()); String output = src.toString().trim(); if (output.startsWith("{namespace ns")) { output = output.substring(output.indexOf('}') + 1).trim(); } assertThat(output).isEqualTo(expectedOutput); } }