diff --git a/web/webkit/src/main/scala/net/liftweb/http/HtmlNormalizer.scala b/web/webkit/src/main/scala/net/liftweb/http/HtmlNormalizer.scala index 2638c5f7ef..a40505c867 100644 --- a/web/webkit/src/main/scala/net/liftweb/http/HtmlNormalizer.scala +++ b/web/webkit/src/main/scala/net/liftweb/http/HtmlNormalizer.scala @@ -87,6 +87,7 @@ private[http] final object HtmlNormalizer { attributes: MetaData, contextPath: String, shouldRewriteUrl: Boolean, // whether to apply URLRewrite.rewriteFunc + extractInlineJavaScript: Boolean, eventAttributes: List[EventAttribute] = Nil ): (Option[String], MetaData, List[EventAttribute]) = { if (attributes == Null) { @@ -100,6 +101,7 @@ private[http] final object HtmlNormalizer { attributes.next, contextPath, shouldRewriteUrl, + extractInlineJavaScript, eventAttributes ) @@ -108,7 +110,7 @@ private[http] final object HtmlNormalizer { EventAttribute.EventForAttribute(eventName), attributeValue, remainingAttributes - ) if attributeValue.text.startsWith("javascript:") => + ) if attributeValue.text.startsWith("javascript:") && extractInlineJavaScript => val attributeJavaScript = { // Could be javascript: or javascript://. val base = attributeValue.text.substring(11) @@ -150,7 +152,7 @@ private[http] final object HtmlNormalizer { (id, newMetaData, remainingEventAttributes) - case UnprefixedAttribute(name, attributeValue, _) if name.startsWith("on") => + case UnprefixedAttribute(name, attributeValue, _) if name.startsWith("on") && extractInlineJavaScript => val updatedEventAttributes = EventAttribute(name.substring(2), attributeValue.text) :: remainingEventAttributes @@ -182,13 +184,20 @@ private[http] final object HtmlNormalizer { }.foldLeft(Noop)(_ & _) } - private[http] def normalizeElementAndAttributes(element: Elem, attributeToNormalize: String, contextPath: String, shouldRewriteUrl: Boolean): NodeAndEventJs = { + private[http] def normalizeElementAndAttributes( + element: Elem, + attributeToNormalize: String, + contextPath: String, + shouldRewriteUrl: Boolean, + extractEventJavaScript: Boolean + ): NodeAndEventJs = { val (id, normalizedAttributes, eventAttributes) = normalizeUrlAndExtractEvents( attributeToNormalize, element.attributes, contextPath, - shouldRewriteUrl + shouldRewriteUrl, + extractEventJavaScript ) val attributesIncludingEventsAsData = @@ -226,7 +235,12 @@ private[http] final object HtmlNormalizer { } } - private[http] def normalizeNode(node: Node, contextPath: String, stripComments: Boolean): Option[NodeAndEventJs] = { + private[http] def normalizeNode( + node: Node, + contextPath: String, + stripComments: Boolean, + extractEventJavaScript: Boolean + ): Option[NodeAndEventJs] = { node match { case element: Elem => val (attributeToFix, shouldRewriteUrl) = @@ -250,7 +264,8 @@ private[http] final object HtmlNormalizer { element, attributeToFix, contextPath, - shouldRewriteUrl + shouldRewriteUrl, + extractEventJavaScript ) ) @@ -263,30 +278,28 @@ private[http] final object HtmlNormalizer { } /** - * Base for all the normalizeHtml* implementations; in addition to what it - * usually does, takes an `[[additionalChanges]]` function that is passed a - * state object and the current (post-normalization) node and can adjust the - * state and tweak the normalized nodes or even add more JsCmds to be - * included. That state is in turn passed to any invocations for any of the - * children of the current node. Note that state is '''not''' passed back up - * the node hierarchy, so state updates are '''only''' seen by children of - * the node. + * Normalizes `nodes` to adjust URLs with the given `contextPath`, stripping + * comments if `stripComments` is `true`, and extracting event JavaScript + * into the `js` part of the returned `NodesAndEventJs` if + * `extractEventJavaScript` is `true`. * * See `[[LiftMerge.merge]]` for sample usage. */ def normalizeHtmlAndEventHandlers( nodes: NodeSeq, contextPath: String, - stripComments: Boolean + stripComments: Boolean, + extractEventJavaScript: Boolean ): NodesAndEventJs = { nodes.foldLeft(NodesAndEventJs(Vector[Node](), Noop)) { (soFar, nodeToNormalize) => - normalizeNode(nodeToNormalize, contextPath, stripComments).map { + normalizeNode(nodeToNormalize, contextPath, stripComments, extractEventJavaScript).map { case NodeAndEventJs(normalizedElement: Elem, js: JsCmd) => val NodesAndEventJs(normalizedChildren, childJs) = normalizeHtmlAndEventHandlers( normalizedElement.child, contextPath, - stripComments + stripComments, + extractEventJavaScript ) soFar diff --git a/web/webkit/src/main/scala/net/liftweb/http/LiftMerge.scala b/web/webkit/src/main/scala/net/liftweb/http/LiftMerge.scala index 99389a4665..7860e4d256 100644 --- a/web/webkit/src/main/scala/net/liftweb/http/LiftMerge.scala +++ b/web/webkit/src/main/scala/net/liftweb/http/LiftMerge.scala @@ -164,7 +164,7 @@ private[http] trait LiftMerge { val bodyTail = childInfo.tailInBodyChild && ! tailInBodyChild HtmlNormalizer - .normalizeNode(node, contextPath, stripComments) + .normalizeNode(node, contextPath, stripComments, LiftRules.extractInlineJavaScript) .map { case normalized @ NodeAndEventJs(normalizedElement: Elem, _) => val normalizedChildren = diff --git a/web/webkit/src/main/scala/net/liftweb/http/LiftRules.scala b/web/webkit/src/main/scala/net/liftweb/http/LiftRules.scala index 7461046e9f..0992f6fe30 100644 --- a/web/webkit/src/main/scala/net/liftweb/http/LiftRules.scala +++ b/web/webkit/src/main/scala/net/liftweb/http/LiftRules.scala @@ -403,6 +403,11 @@ class LiftRules() extends Factory with FormVendor with LazyLoggable { /** * Holds the JS library specific UI artifacts. By default it uses JQuery's artifacts + * + * Please note that currently any setting other than `JQueryArtifacts` will switch + * you to using Lift's liftVanilla implementation, which is meant to work independent + * of any framework. '''This implementation is experimental in Lift 3.0, so use it at + * your own risk and make sure you test your application!''' */ @volatile var jsArtifacts: JSArtifacts = JQueryArtifacts @@ -570,11 +575,31 @@ class LiftRules() extends Factory with FormVendor with LazyLoggable { @volatile var displayHelpfulSiteMapMessages_? = true /** - * The attribute used to expose the names of event attributes that - * were removed from a given element for separate processing in JS. - * By default, Lift removes event attributes and attaches those - * behaviors via a separate JS file, to avoid inline JS invocations so - * that a restrictive Content-Security-Policy can be used. + * Enables or disables event attribute and script element extraction. + * + * Lift can extract script elements and event attributes like onclick, + * onchange, etc, and attach the event handlers in a separate JavaScript file + * that is generated per-page. This allows for populating these types of + * JavaScript in your snippets via CSS selector transforms, without needing + * to allow inline scripts in your content security policy (see + * `[[securityRules]]`). + * + * However, there are certain scenarios where event attribute extraction + * cannot provide a 1-to-1 reproduction of the behavior you'd get with inline + * attributes or scripts; if your application hits these scenarios and you + * would prefer not to adjust them to work with a restrictive content + * security policy, you can allow inline scripts and set + * `extractEventAttributes` to false to disable event extraction. + */ + @volatile var extractInlineJavaScript: Boolean = false + + /** + * The attribute used to expose the names of event attributes that were + * removed from a given element for separate processing in JS (when + * `extractInlineJavaScript` is `true`). By default, Lift removes event + * attributes and attaches those behaviors via a separate JS file, to avoid + * inline JS invocations so that a restrictive content security policy can be + * used. * * You can set this variable so that the resulting HTML will have * attribute information about the removed attributes, in case you diff --git a/web/webkit/src/main/scala/net/liftweb/http/LiftSession.scala b/web/webkit/src/main/scala/net/liftweb/http/LiftSession.scala index 0c679846f7..ce8896e5b6 100644 --- a/web/webkit/src/main/scala/net/liftweb/http/LiftSession.scala +++ b/web/webkit/src/main/scala/net/liftweb/http/LiftSession.scala @@ -1873,7 +1873,8 @@ class LiftSession(private[http] val _contextPath: String, val underlyingId: Stri HtmlNormalizer.normalizeHtmlAndEventHandlers( nodes, S.contextPath, - LiftRules.stripComments.vend + LiftRules.stripComments.vend, + LiftRules.extractInlineJavaScript ) } diff --git a/web/webkit/src/test/scala/net/liftweb/http/HtmlNormalizerSpec.scala b/web/webkit/src/test/scala/net/liftweb/http/HtmlNormalizerSpec.scala index 6ac7f98226..1ec984480c 100644 --- a/web/webkit/src/test/scala/net/liftweb/http/HtmlNormalizerSpec.scala +++ b/web/webkit/src/test/scala/net/liftweb/http/HtmlNormalizerSpec.scala @@ -40,7 +40,8 @@ class HtmlNormalizerSpec extends Specification with XmlMatchers with Mockito { , "/context-path", - false + false, + true ).nodes result must ==/( @@ -85,7 +86,8 @@ class HtmlNormalizerSpec extends Specification with XmlMatchers with Mockito { , "/context-path", - false + false, + true ) List("testJs1", @@ -113,7 +115,8 @@ class HtmlNormalizerSpec extends Specification with XmlMatchers with Mockito { HtmlNormalizer.normalizeHtmlAndEventHandlers( , "/context-path", - false + false, + true ) html must ==/() @@ -125,7 +128,8 @@ class HtmlNormalizerSpec extends Specification with XmlMatchers with Mockito { HtmlNormalizer.normalizeHtmlAndEventHandlers( , "/context-path", - false + false, + true ) val id = html \@ "id" @@ -143,7 +147,8 @@ class HtmlNormalizerSpec extends Specification with XmlMatchers with Mockito { , "/context-path", - false + false, + true ) js.toJsCmd must be matching("""(?s)\Qlift.onEvent("lift-event-js-\E[^"]+\Q","event",function(event) {doStuff;}); @@ -165,7 +170,8 @@ class HtmlNormalizerSpec extends Specification with XmlMatchers with Mockito { , "/context-path", - false + false, + true ) (html \ "myelement").map(_ \@ "href").filter(_.nonEmpty) must beEmpty @@ -187,7 +193,8 @@ class HtmlNormalizerSpec extends Specification with XmlMatchers with Mockito { , "/context-path", - false + false, + true ) (html \ "myelement").map(_ \@ "href").filter(_.nonEmpty) must_== List("doStuff", "javascrip://doStuff3") @@ -216,10 +223,329 @@ class HtmlNormalizerSpec extends Specification with XmlMatchers with Mockito { , "/context-path", + false, + true + ).nodes + + (result \\ "link").map(_ \@ "href") must_== + "/context-path/testlink" :: + "/context-path/testlink2" :: + "/context-path/testlink3" :: Nil + } + + "normalize absolute script srcs everywhere" in { + val result = + HtmlNormalizer.normalizeHtmlAndEventHandlers( + + + + + + + + +
+

+ +

+
+ +

Thingies

+

More thingies

+ + , + "/context-path", + false, + true + ).nodes + + (result \\ "script").map(_ \@ "src") must_== + "/context-path/testscript" :: + "/context-path/testscript2" :: Nil + } + + "normalize absolute a hrefs everywhere" in { + val result = + HtmlNormalizer.normalizeHtmlAndEventHandlers( + + + Booyan + + + Booyan + Booyan +
+ Booyan +

+ Booyan +

+
+ +

Thingies Booyan

+

More thingies

+ + , + "/context-path", + false, + true + ).nodes + + (result \\ "a").map(_ \@ "href") must_== + "/context-path/testa1" :: + "/context-path/testa2" :: + "testa3" :: + "testa4" :: + "/context-path/testa5" :: + "/context-path/testa6" :: Nil + } + + "normalize absolute form actions everywhere" in { + val result = + HtmlNormalizer.normalizeHtmlAndEventHandlers( + + +
Booyan
+ + +
Booyan
+
Booyan
+
+
Booyan
+

+

Booyan
+

+
+ +

Thingies

Booyan

+

More thingies

+ + , + "/context-path", + false, + true + ).nodes + + (result \\ "form").map(_ \@ "action") must_== + "/context-path/testform1" :: + "/context-path/testform2" :: + "testform3" :: + "testform4" :: + "/context-path/testform5" :: + "/context-path/testform6" :: Nil + } + + "not rewrite script srcs anywhere" in { + val result = + URLRewriter.doWith((_: String) => "rewritten") { + HtmlNormalizer.normalizeHtmlAndEventHandlers( + + + + + + +
+

+ + + + + +

+

+ +

+
+ +

Thingies

+

More thingies

+ + + + val NodesAndEventJs(html, js) = + HtmlNormalizer.normalizeHtmlAndEventHandlers( + startingHtml, + "/context-path", + false, + false + ) + + html.toString must_== startingHtml.toString + js.toJsCmd.length must_== 0 + } + + "not extract events from hrefs and actions" in { + val startingHtml = +
+ + + + // Note here we have the same behavior as browsers: javascript:/ + // is *processed as JavaScript* but it is *invalid JavaScript* + // (i.e., it corresponds to a JS expression that starts with `/`). + +
+ + val NodesAndEventJs(html, js) = + HtmlNormalizer.normalizeHtmlAndEventHandlers( + startingHtml, + "/context-path", + false, + false + ) + + html.toString must_== startingHtml.toString + js.toJsCmd.length must_== 0 + } + + "normalize absolute link hrefs everywhere" in { + val result = + HtmlNormalizer.normalizeHtmlAndEventHandlers( + + + + + + + +
+

+ +

+
+ +

Thingies

+

More thingies

+ + , + "/context-path", + false, false ).nodes - (result \\ "link").map(_ \@ "href") must_== + (result \\ "link").map(_ \@ "href") must_== "/context-path/testlink" :: "/context-path/testlink2" :: "/context-path/testlink3" :: Nil @@ -247,10 +573,11 @@ class HtmlNormalizerSpec extends Specification with XmlMatchers with Mockito { , "/context-path", + false, false ).nodes - (result \\ "script").map(_ \@ "src") must_== + (result \\ "script").map(_ \@ "src") must_== "/context-path/testscript" :: "/context-path/testscript2" :: Nil } @@ -277,10 +604,11 @@ class HtmlNormalizerSpec extends Specification with XmlMatchers with Mockito { , "/context-path", + false, false ).nodes - (result \\ "a").map(_ \@ "href") must_== + (result \\ "a").map(_ \@ "href") must_== "/context-path/testa1" :: "/context-path/testa2" :: "testa3" :: @@ -311,10 +639,11 @@ class HtmlNormalizerSpec extends Specification with XmlMatchers with Mockito { , "/context-path", + false, false ).nodes - (result \\ "form").map(_ \@ "action") must_== + (result \\ "form").map(_ \@ "action") must_== "/context-path/testform1" :: "/context-path/testform2" :: "testform3" :: @@ -344,11 +673,12 @@ class HtmlNormalizerSpec extends Specification with XmlMatchers with Mockito { , "/context-path", + false, false ).nodes } - (result \\ "script").map(_ \@ "src") must_== + (result \\ "script").map(_ \@ "src") must_== "testscript" :: "testscript2" :: "testscript3" :: Nil @@ -375,11 +705,12 @@ class HtmlNormalizerSpec extends Specification with XmlMatchers with Mockito { , "/context-path", + false, false ).nodes } - (result \\ "link").map(_ \@ "href") must_== + (result \\ "link").map(_ \@ "href") must_== "testlink" :: "testlink2" :: "testlink3" :: Nil @@ -406,11 +737,12 @@ class HtmlNormalizerSpec extends Specification with XmlMatchers with Mockito { , "/context-path", + false, false ).nodes } - (result \\ "a").map(_ \@ "href") must_== + (result \\ "a").map(_ \@ "href") must_== "rewritten" :: "rewritten" :: "rewritten" :: Nil @@ -437,11 +769,12 @@ class HtmlNormalizerSpec extends Specification with XmlMatchers with Mockito { , "/context-path", + false, false ).nodes } - (result \\ "form").map(_ \@ "action") must_== + (result \\ "form").map(_ \@ "action") must_== "rewritten" :: "rewritten" :: "rewritten" :: Nil