Author: dan.j.allen
Date: 2009-04-02 18:52:19 -0400 (Thu, 02 Apr 2009)
New Revision: 10279
Added:
trunk/ui/src/main/java/org/jboss/seam/ui/ClientUidSelector.java
trunk/ui/src/main/java/org/jboss/seam/ui/UnauthorizedCommandException.java
trunk/ui/src/main/java/org/jboss/seam/ui/component/UIToken.java
trunk/ui/src/main/java/org/jboss/seam/ui/renderkit/TokenRendererBase.java
Modified:
trunk/doc/Seam_Reference_Guide/en-US/Controls.xml
Log:
JBSEAM-4007
Modified: trunk/doc/Seam_Reference_Guide/en-US/Controls.xml
===================================================================
--- trunk/doc/Seam_Reference_Guide/en-US/Controls.xml 2009-04-02 21:22:40 UTC (rev 10278)
+++ trunk/doc/Seam_Reference_Guide/en-US/Controls.xml 2009-04-02 22:52:19 UTC (rev 10279)
@@ -850,7 +850,48 @@
</section>
<section>
- <title>Dropdowns</title>
+ <title>Form support</title>
+
+ <section>
+
<title><literal><s:token></literal></title>
+
+ <para><emphasis>Description</emphasis></para>
+ <para>
+ Produces a random token that is inserted into a hidden form field
+ to help to secure JSF form posts against cross-site request
+ forgery (XSRF) attacks. Note that the browser must have cookies
+ enabled to submit forms that include this component.
+ </para>
+
+ <para><emphasis>Attributes</emphasis></para>
+ <itemizedlist>
+ <listitem>
+ <para>
+ <literal>requireSession</literal> — indicates
+ whether the session id should be included in the form
+ signature, hence binding the token to the session. This
+ value can be set to false if the "build before restore"
+ mode of Facelets is activated (the default in JSF 2.0).
+ (default: false)
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>enableCookieNotice</literal> —
indicates
+ that a JavaScript check should be inserted into the page
+ to verify that cookies are enabled in the browser. If
+ cookies are not enabled, present a notice to the user that
+ form posts will not work.
+ (default: false)
+ </para>
+ </listitem>
+ </itemizedlist>
+ <para><emphasis>Usage</emphasis></para>
+ <programlisting role="XHTML"><![CDATA[<h:form>
+ <s:token enableCookieNotice="true" requireSession="false"/>
+ ...
+</h:form>]]></programlisting>
+ </section>
<section>
<title><literal><s:enumItem></literal></title>
@@ -958,65 +999,6 @@
<s:selectItems value="#{ages}" var="age"
label="#{age}" />
</h:selectOneMenu>]]></programlisting>
</section>
-
- </section>
-
- <section>
- <title>Other</title>
-
- <section>
-
<title><literal><s:cache></literal></title>
-
- <para><emphasis>Description</emphasis></para>
- <para>
- Cache the rendered page fragment using JBoss Cache. Note that
- <literal><s:cache></literal> actually uses the
instance
- of JBoss Cache managed by the built-in
- <literal>pojoCache</literal> component.
- </para>
- <para><emphasis>Attributes</emphasis></para>
- <itemizedlist>
- <listitem>
- <para>
- <literal>key</literal> — the key to cache
rendered
- content, often a value expression. For example, if we were
- caching a page fragment that displays a document, we might
- use
<literal>key="Document-#{document.id}"</literal>.
- </para>
- </listitem>
- <listitem>
- <para>
- <literal>enabled</literal> — a value
expression that
- determines if the cache should be used.
- </para>
- </listitem>
- <listitem>
- <para>
- <literal>region</literal> — a JBoss Cache node
to use
- (different nodes can have different expiry policies).
- </para>
- </listitem>
- </itemizedlist>
-
- <para><emphasis>Usage</emphasis></para>
- <programlisting role="XHTML"><![CDATA[<s:cache
key="entry-#{blogEntry.id}" region="pageFragments">
- <div class="blogEntry">
- <h3>#{blogEntry.title}</h3>
- <div>
- <s:formattedText value="#{blogEntry.body}"/>
- </div>
- <p>
- [Posted on 
- <h:outputText value="#{blogEntry.date}">
- <f:convertDateTime timezone="#{blog.timeZone}"
locale="#{blog.locale}"
- type="both"/>
- </h:outputText>]
- </p>
- </div>
-</s:cache>]]></programlisting>
-
- </section>
-
<section>
<title><literal><s:fileUpload></literal></title>
@@ -1131,7 +1113,66 @@
contentType="#{register.pictureContentType}"
/>]]></programlisting>
</section>
+
+ </section>
+
+ <section>
+ <title>Other</title>
+
+ <section>
+
<title><literal><s:cache></literal></title>
+ <para><emphasis>Description</emphasis></para>
+ <para>
+ Cache the rendered page fragment using JBoss Cache. Note that
+ <literal><s:cache></literal> actually uses the
instance
+ of JBoss Cache managed by the built-in
+ <literal>pojoCache</literal> component.
+ </para>
+ <para><emphasis>Attributes</emphasis></para>
+ <itemizedlist>
+ <listitem>
+ <para>
+ <literal>key</literal> — the key to cache
rendered
+ content, often a value expression. For example, if we were
+ caching a page fragment that displays a document, we might
+ use
<literal>key="Document-#{document.id}"</literal>.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>enabled</literal> — a value
expression that
+ determines if the cache should be used.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>region</literal> — a JBoss Cache node
to use
+ (different nodes can have different expiry policies).
+ </para>
+ </listitem>
+ </itemizedlist>
+
+ <para><emphasis>Usage</emphasis></para>
+ <programlisting role="XHTML"><![CDATA[<s:cache
key="entry-#{blogEntry.id}" region="pageFragments">
+ <div class="blogEntry">
+ <h3>#{blogEntry.title}</h3>
+ <div>
+ <s:formattedText value="#{blogEntry.body}"/>
+ </div>
+ <p>
+ [Posted on 
+ <h:outputText value="#{blogEntry.date}">
+ <f:convertDateTime timezone="#{blog.timeZone}"
locale="#{blog.locale}"
+ type="both"/>
+ </h:outputText>]
+ </p>
+ </div>
+</s:cache>]]></programlisting>
+
+ </section>
+
+
<section>
<title><literal><s:resource></literal></title>
Added: trunk/ui/src/main/java/org/jboss/seam/ui/ClientUidSelector.java
===================================================================
--- trunk/ui/src/main/java/org/jboss/seam/ui/ClientUidSelector.java
(rev 0)
+++ trunk/ui/src/main/java/org/jboss/seam/ui/ClientUidSelector.java 2009-04-02 22:52:19
UTC (rev 10279)
@@ -0,0 +1,58 @@
+package org.jboss.seam.ui;
+
+import javax.faces.context.FacesContext;
+
+import org.jboss.seam.annotations.Create;
+import org.jboss.seam.annotations.Name;
+import org.jboss.seam.faces.Selector;
+import org.jboss.seam.util.RandomStringUtils;
+
+/**
+ * <p>A selector which manages the cookie that gives the browser a
+ * unique identifier. This value is shared only between the browser
+ * and the server, thus allowing the server to determine if two
+ * distinct requests were made by the same source.</p>
+ *
+ * <p>The identifier is stored in a cookie named
<code>javax.faces.ClientToken</code>.</p>
+ *
+ * @author Dan Allen
+ */
+(a)Name("org.jboss.seam.ui.clientUidSelector")
+public class ClientUidSelector extends Selector
+{
+ private String clientUid;
+
+ @Create
+ public void onCreate()
+ {
+
setCookiePath(FacesContext.getCurrentInstance().getExternalContext().getRequestContextPath());
+ setCookieMaxAge(-1);
+ setCookieEnabled(true);
+ clientUid = getCookieValue();
+ }
+
+ public void seed()
+ {
+ if (!isSet()) {
+ clientUid = RandomStringUtils.randomAscii(50);
+ setCookieValueIfEnabled(clientUid);
+ }
+ }
+
+ public boolean isSet()
+ {
+ return clientUid != null;
+ }
+
+ public String getClientUid()
+ {
+ return clientUid;
+ }
+
+ @Override
+ protected String getCookieName()
+ {
+ return "javax.faces.ClientToken";
+ }
+
+}
Added: trunk/ui/src/main/java/org/jboss/seam/ui/UnauthorizedCommandException.java
===================================================================
--- trunk/ui/src/main/java/org/jboss/seam/ui/UnauthorizedCommandException.java
(rev 0)
+++ trunk/ui/src/main/java/org/jboss/seam/ui/UnauthorizedCommandException.java 2009-04-02
22:52:19 UTC (rev 10279)
@@ -0,0 +1,50 @@
+package org.jboss.seam.ui;
+
+import javax.faces.FacesException;
+
+/**
+ * An exception is thrown when the authenticity of a JSF command (i.e., form post)
+ * that relies on a UIToken cannot be verified.
+ *
+ * @author Dan Allen
+ */
+public class UnauthorizedCommandException extends FacesException
+{
+ private String viewId;
+
+ /**
+ * <p>Construct a new exception with no detail message or root cause.</p>
+ */
+ public UnauthorizedCommandException() {
+ super();
+ }
+
+ /**
+ * <p>Construct a new exception with a detail message and the view ID</p>
+ */
+ public UnauthorizedCommandException(String viewId, String message) {
+ super(message);
+ this.viewId = viewId;
+ }
+
+ /**
+ * <p>Returns the view ID to which the authorized command was
directed.</p>
+ */
+ public String getViewId()
+ {
+ return viewId;
+ }
+
+ /**
+ * <p>Returns the detail message explaining the reason for the denial.
+ * Includes the view ID if specified.</p>
+ */
+ @Override
+ public String getMessage()
+ {
+ if (viewId != null) {
+ return "viewId: " + viewId + " - " + super.getMessage();
+ }
+ return super.getMessage();
+ }
+}
Added: trunk/ui/src/main/java/org/jboss/seam/ui/component/UIToken.java
===================================================================
--- trunk/ui/src/main/java/org/jboss/seam/ui/component/UIToken.java
(rev 0)
+++ trunk/ui/src/main/java/org/jboss/seam/ui/component/UIToken.java 2009-04-02 22:52:19
UTC (rev 10279)
@@ -0,0 +1,110 @@
+package org.jboss.seam.ui.component;
+
+import javax.faces.component.UIForm;
+import javax.faces.component.UIOutput;
+
+import org.jboss.seam.Component;
+import org.jboss.seam.ui.ClientUidSelector;
+import org.jboss.seam.ui.UnauthorizedCommandException;
+
+/**
+ * <p>
+ * <strong>UIToken</strong> is a UIComponent that produces a random token
that
+ * is inserted into a hidden form field to help to secure JSF form posts against
+ * cross-site request forgery (XSRF) attacks. This is an adaptation of the
+ * recommendation called Keyed‐Hashing for Message Authentication that is
+ * referenced in the Cross Site Reference Forgery by Jesse Burns
+ * (
http://www.isecpartners.com/files/XSRF_Paper_0.pdf)
+ * </p>
+ *
+ * <p>
+ * When placed inside a form, this component will first assign a unique
+ * identifier to the browser using a cookie that lives until the end of the
+ * browser session. This is roughly the browser's private key. Then a unique
+ * token is generated using various pieces of information that comprise the
+ * form's signature. The token may or may not be bound to the session id, as
+ * indicated by the value of the requireSession attribute. The token value is
+ * stored in the hidden form field named javax.faces.FormSignature.
+ * </p>
+ *
+ * <p>
+ * There is an assumption when using this component that the browser supports
+ * cookies. Cookies are the only universally available persistent mechanism that
+ * can give the browser an identifiable signature. It's important to know that
+ * the browser submitting the form is the same browser that is requesting the
+ * form.
+ * </p>
+ *
+ * <p>
+ * During the decode process, the token is generated using the same algorithm
+ * that was used during rendering and compared with the value of the request
+ * parameter javax.faces.FormSignature. If the same token value can be produced,
+ * then the form submission is permitted. Otherwise, an
+ * {@link UnauthorizedCommandException} is thrown indicating the reason for the
+ * failure.
+ * </p>
+ *
+ * <p>
+ * The UIToken can be combined with client-side state saving or the
+ * "build before restore" strategy to unbind a POST from the session that
+ * created the view without sacrificing security. However, it's still the most
+ * secure to require the view state to be present in the session (JSF 1.2
+ * server-side state saving).
+ * </p>
+ *
+ * <p>
+ * Please note that this solution isn't a complete panacea. If your site is
+ * vulnerable to XSS or the connection to wire-tapping, then the unique browser
+ * identifier can be revealed and a request forged.
+ * </p>
+ *
+ * @author Dan Allen
+ */
+public abstract class UIToken extends UIOutput
+{
+ @SuppressWarnings("unused")
+ private static final String COMPONENT_TYPE = "org.jboss.seam.ui.Token";
+
+ @SuppressWarnings("unused")
+ private static final String COMPONENT_FAMILY = "org.jboss.seam.ui.Token";
+
+ /**
+ * Indicates whether the session id should be included in the form signature,
+ * hence binding the token to the session. This value can be set to false
+ * if the "build before restore" mode of Facelets is activated (the
+ * default in JSF 2.0).
+ */
+ public abstract boolean isRequireSession();
+
+ public abstract void setRequireSession(boolean required);
+
+ /**
+ * Indicates whether a JavaScript check should be inserted into the page to
+ * verify that cookies are enabled in the browser. If cookies are not
+ * enabled, present a notice to the user that form posts will not work.
+ */
+ public abstract boolean isEnableCookieNotice();
+
+ public abstract void setEnableCookieNotice(boolean state);
+
+ /**
+ * Return the selector that controls the unique browser identifier cookie.
+ */
+ public ClientUidSelector getClientUidSelector() {
+ return (ClientUidSelector) Component.getInstance(ClientUidSelector.class);
+ }
+
+ public String getClientUid() {
+ return getClientUidSelector().getClientUid();
+ }
+
+ public UIForm getParentForm() {
+ while (getParent() != null) {
+ if (getParent() instanceof UIForm) {
+ return (UIForm) getParent();
+ }
+ }
+
+ return null;
+ }
+}
Added: trunk/ui/src/main/java/org/jboss/seam/ui/renderkit/TokenRendererBase.java
===================================================================
--- trunk/ui/src/main/java/org/jboss/seam/ui/renderkit/TokenRendererBase.java
(rev 0)
+++ trunk/ui/src/main/java/org/jboss/seam/ui/renderkit/TokenRendererBase.java 2009-04-02
22:52:19 UTC (rev 10279)
@@ -0,0 +1,165 @@
+package org.jboss.seam.ui.renderkit;
+
+import java.io.IOException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+import javax.faces.component.UIComponent;
+import javax.faces.component.UIForm;
+import javax.faces.context.FacesContext;
+import javax.faces.context.ResponseWriter;
+import javax.servlet.http.HttpSession;
+
+import org.jboss.seam.ui.ClientUidSelector;
+import org.jboss.seam.ui.UnauthorizedCommandException;
+import org.jboss.seam.ui.component.UIToken;
+import org.jboss.seam.ui.util.HTML;
+import org.jboss.seam.ui.util.cdk.RendererBase;
+import org.jboss.seam.util.Base64;
+import org.jboss.seam.util.RandomStringUtils;
+
+/**
+ * <p>
+ * The <strong>TokenRendererBase</strong> renders the form's signature as
a
+ * hidden form field for the UIToken component.
+ * </p>
+ *
+ * <p>
+ * The form signature is calculated as follows:
+ * </p>
+ *
+ * <pre>
+ * sha1(signature = contextPath + viewId + "," + formClientId + random
alphanum, salt = clientUid)
+ * </pre>
+ *
+ * <p>
+ * The developer can also choose to incorporate the session id into this hash to
+ * generate a more secure token (at the cost of binding it to the session) by
+ * setting the requireSession attribute to true. Then the calculation becomes:
+ * </p>
+ *
+ * <pre>
+ * sha1(signature = contextPath + viewId + "," + formClientId +
"," + random alphanum + sessionId, salt = clientUid)
+ * </pre>
+ *
+ * <p>The decode method performs the following steps:</p>
+ * <ol>
+ * <li>check if this is a postback, otherwise skip the check</li>
+ * <li>check that this form was the one that was submitted, otherwise skip the
check</li>
+ * <li>get the unique client identifier (from cookie), otherwise throw an exception
that the browser must have unique identifier</li>
+ * <li>get the javax.faces.FormSignature request parameter, otherwise throw an
exception that the form signature is missing</li>
+ * <li>generate the hash as before and verify that it equals the value of the
javax.faces.FormSignature request parameter, otherwise throw an exception</li>
+ * </ol>
+ *
+ * <p>If all of that passes, we are okay to process the form (advance to validate
phase as decode() is called in apply request values).</p>
+ *
+ * @author Dan Allen
+ * @see UnauthorizedCommandException
+ */
+public class TokenRendererBase extends RendererBase
+{
+ public static final String FORM_SIGNATURE_PARAM =
"javax.faces.FormSignature";
+
+ public static final String RENDER_STAMP_ATTR = "javax.faces.RenderStamp";
+
+ private static final String COOKIE_CHECK_SCRIPT_KEY =
"org.jboss.seam.ui.COOKIE_CHECK_SCRIPT";
+
+ @Override
+ protected Class getComponentClass()
+ {
+ return UIToken.class;
+ }
+
+ @Override
+ protected void doDecode(FacesContext context, UIComponent component)
+ {
+ UIToken token = (UIToken) component;
+ UIForm form = token.getParentForm();
+ if (context.getRenderKit().getResponseStateManager().isPostback(context) &&
form.isSubmitted())
+ {
+ String clientToken = token.getClientUid();
+ String viewId = context.getViewRoot().getViewId();
+ if (clientToken == null)
+ {
+ throw new UnauthorizedCommandException(viewId, "No client identifier
provided");
+ }
+
+ String requestedViewSig =
context.getExternalContext().getRequestParameterMap().get(FORM_SIGNATURE_PARAM);
+ if (requestedViewSig == null)
+ {
+ throw new UnauthorizedCommandException(viewId, "No form signature
provided");
+ }
+
+ if (!requestedViewSig.equals(generateViewSignature(context, form,
token.isRequireSession(), clientToken)))
+ {
+ throw new UnauthorizedCommandException(viewId, "Form signature
invalid");
+ }
+
+ form.getAttributes().remove(RENDER_STAMP_ATTR);
+ }
+ }
+
+ @Override
+ protected void doEncodeBegin(ResponseWriter writer, FacesContext context, UIComponent
component) throws IOException
+ {
+ UIToken token = (UIToken) component;
+ UIForm form = token.getParentForm();
+ if (form == null)
+ {
+ throw new IllegalStateException("UIToken must be inside a UIForm.");
+ }
+
+ writeCookieCheckScript(context, writer, token);
+
+ token.getClientUidSelector().seed();
+ form.getAttributes().put(RENDER_STAMP_ATTR,
RandomStringUtils.randomAlphanumeric(50));
+ writer.startElement(HTML.INPUT_ELEM, component);
+ writer.writeAttribute(HTML.TYPE_ATTR, HTML.INPUT_TYPE_HIDDEN, HTML.TYPE_ATTR);
+ writer.writeAttribute(HTML.NAME_ATTR, FORM_SIGNATURE_PARAM, HTML.NAME_ATTR);
+ writer.writeAttribute(HTML.VALUE_ATTR, generateViewSignature(context, form,
token.isRequireSession(), token.getClientUidSelector().getClientUid()), HTML.VALUE_ATTR);
+ writer.endElement(HTML.INPUT_ELEM);
+ }
+
+ /**
+ * If the client has not already delivered us a cookie and the cookie notice is
enabled, write out JavaScript that will show the user
+ * an alert if cookies are not enabled.
+ */
+ private void writeCookieCheckScript(FacesContext context, ResponseWriter writer,
UIToken token) throws IOException
+ {
+ if (!token.getClientUidSelector().isSet() && token.isEnableCookieNotice()
&&
!context.getExternalContext().getRequestMap().containsKey(COOKIE_CHECK_SCRIPT_KEY)) {
+ writer.startElement(HTML.SCRIPT_ELEM, token);
+ writer.writeAttribute(HTML.TYPE_ATTR, "text/javascript",
HTML.TYPE_ATTR);
+ writer.write("if (!document.cookie) {" +
+ " alert('This website uses a security measure that requires cookies
to be enabled in your browser. Since you have cookies disabled, you will not be permitted
to submit a form.');" +
+ " }");
+ writer.endElement(HTML.SCRIPT_ELEM);
+ context.getExternalContext().getRequestMap().put(COOKIE_CHECK_SCRIPT_KEY,
true);
+ }
+ }
+
+ private String generateViewSignature(FacesContext context, UIForm form, boolean
useSessionId, String saltPhrase)
+ {
+ String rawViewSignature = context.getExternalContext().getRequestContextPath() +
"," + context.getViewRoot().getViewId() + "," +
form.getClientId(context) + "," + form.getAttributes().get(RENDER_STAMP_ATTR);
+ if (useSessionId)
+ {
+ rawViewSignature += "," + ((HttpSession)
context.getExternalContext().getSession(true)).getId();
+ }
+ try
+ {
+ MessageDigest digest = MessageDigest.getInstance("SHA-1");
+ digest.update(saltPhrase.getBytes());
+ byte[] salt = digest.digest();
+ digest.reset();
+ digest.update(rawViewSignature.getBytes());
+ digest.update(salt);
+ byte[] raw = digest.digest();
+ return Base64.encodeBytes(raw);
+ }
+ catch (NoSuchAlgorithmException ex)
+ {
+ ex.printStackTrace();
+ return null;
+ }
+ }
+
+}