Repository: shapesecurity/salvation (java)

Issue:
Support report-to directive
Add support for [`report-to`](http://w3c.github.io/webappsec-csp/#directive-report-to) directive.

Pull request:
fixes #98: support for report-to directive

Patch:
diff --git a/src/main/java/com/shapesecurity/salvation/Constants.java b/src/main/java/com/shapesecurity/salvation/Constants.java
index f0d9671..c9d6e79 100644
--- a/src/main/java/com/shapesecurity/salvation/Constants.java
+++ b/src/main/java/com/shapesecurity/salvation/Constants.java
@@ -7,7 +7,7 @@ import java.util.regex.Pattern;
     public static final Pattern referrerTokenPattern = Pattern
         .compile("^(?:no-referrer|no-referrer-when-downgrade|origin" + "|origin-when-cross-origin|unsafe-url)$",
             Pattern.CASE_INSENSITIVE);
-    public static final Pattern sandboxTokenPattern = Pattern.compile("^[!#$%&'*+\\-.^_`|~0-9a-zA-Z]+$");
+    public static final Pattern rfc7230TokenPattern = Pattern.compile("^[!#$%&'*+\\-.^_`|~0-9a-zA-Z]+$");
     public static final Pattern sandboxEnumeratedTokenPattern = Pattern.compile(
         "^allow-(?:forms|modals|pointer-lock" + "|popups|popups-to-escape-sandbox|same-origin"
             + "|scripts|top-navigation)$");
diff --git a/src/main/java/com/shapesecurity/salvation/Parser.java b/src/main/java/com/shapesecurity/salvation/Parser.java
index 263700a..f3a4761 100644
--- a/src/main/java/com/shapesecurity/salvation/Parser.java
+++ b/src/main/java/com/shapesecurity/salvation/Parser.java
@@ -37,6 +37,10 @@ public class Parser {
         new DirectiveParseException("Invalid referrer-token list");
     private static final DirectiveValueParseException INVALID_REFERRER_TOKEN =
         new DirectiveValueParseException("Invalid referrer-token");
+    private static final DirectiveParseException INVALID_REPORT_TO_TOKEN_LIST =
+        new DirectiveParseException("Invalid report-to token list");
+    private static final DirectiveValueParseException INVALID_REPORT_TO_TOKEN =
+        new DirectiveValueParseException("Invalid report-to token");
     private static final DirectiveParseException INVALID_SANDBOX_TOKEN_LIST =
         new DirectiveParseException("Invalid sandbox-token list");
     private static final DirectiveValueParseException INVALID_SANDBOX_TOKEN =
@@ -291,7 +295,18 @@ public class Parser {
                     }
                     result = new ReferrerDirective(referrerTokens);
                     break;
+                case ReportTo:
+                    Set<ReportToValue> reportToTokens = this.parseReportToTokenList();
+                    if (reportToTokens.isEmpty()) {
+                        this.error(token, "The report-to directive must contain exactly one token");
+                        throw INVALID_REPORT_TO_TOKEN_LIST;
+                    }
+                    result = new ReportToDirective(reportToTokens);
+                    break;
                 case ReportUri:
+                    // TODO: bump to .warn once CSP3 becomes RC
+                    this.info(token,
+                        "A draft of the next version of CSP deprecates report-uri in favour of a new report-to directive.");
                     Set<URI> uriList = this.parseUriList();
                     if (uriList.isEmpty()) {
                         this.error(token, "The report-uri directive must contain at least one uri-reference");
@@ -572,7 +587,6 @@ public class Parser {
     }
 
     @Nonnull private Set<ReferrerValue> parseReferrerTokenList() throws DirectiveParseException {
-
         Set<ReferrerValue> referrerTokens = new LinkedHashSet<>();
         if (this.hasNext(DirectiveValueToken.class)) {
             boolean parseException = false;
@@ -606,6 +620,41 @@ public class Parser {
         throw INVALID_REFERRER_TOKEN;
     }
 
+    @Nonnull private Set<ReportToValue> parseReportToTokenList() throws DirectiveParseException {
+        Set<ReportToValue> reportToTokens = new LinkedHashSet<>();
+        if (this.hasNext(DirectiveValueToken.class)) {
+            boolean parseException = false;
+            Token dv = this.advance();
+            for (SubDirectiveValueToken subdv : splitByWSP(dv)) {
+                try {
+                    ReportToValue rv = this.parseReportToToken(subdv);
+                    if (!reportToTokens.isEmpty()) {
+                        this.error(subdv, "The report-to directive must contain exactly one token");
+                        throw INVALID_REPORT_TO_TOKEN_LIST;
+                    }
+                    reportToTokens.add(rv);
+                } catch (DirectiveValueParseException e) {
+                    parseException = true;
+                }
+            }
+            if (parseException) {
+                throw INVALID_REPORT_TO_TOKEN_LIST;
+            }
+        }
+        return reportToTokens;
+    }
+
+    @Nonnull private ReportToValue parseReportToToken(@Nonnull SubDirectiveValueToken token)
+        throws DirectiveValueParseException {
+        Matcher matcher = Constants.rfc7230TokenPattern.matcher(token.value);
+        if (matcher.find()) {
+            return new ReportToValue(token.value);
+        }
+        this.error(token, "Expecting report-to token but found " + token.value);
+        throw INVALID_REPORT_TO_TOKEN;
+    }
+
+
     @Nonnull private Set<SandboxValue> parseSandboxTokenList() throws DirectiveParseException {
         Set<SandboxValue> sandboxTokens = new LinkedHashSet<>();
         if (this.hasNext(DirectiveValueToken.class)) {
@@ -634,7 +683,7 @@ public class Parser {
             this.warn(token, "The sandbox directive should contain only allow-forms, allow-modals, "
                 + "allow-pointer-lock, allow-popups, allow-popups-to-escape-sandbox, "
                 + "allow-same-origin, allow-scripts, or allow-top-navigation");
-            matcher = Constants.sandboxTokenPattern.matcher(token.value);
+            matcher = Constants.rfc7230TokenPattern.matcher(token.value);
             if (matcher.find()) {
                 return new SandboxValue(token.value);
             }
diff --git a/src/main/java/com/shapesecurity/salvation/data/Policy.java b/src/main/java/com/shapesecurity/salvation/data/Policy.java
index ab4c348..e0f31de 100644
--- a/src/main/java/com/shapesecurity/salvation/data/Policy.java
+++ b/src/main/java/com/shapesecurity/salvation/data/Policy.java
@@ -45,6 +45,13 @@ public class Policy implements Show {
                 "Cannot merge policies if either policy contains a report-uri directive.");
         }
 
+        if (this.directives.containsKey(ReportToDirective.class) || other.directives
+            .containsKey(ReportToDirective.class)) {
+            throw new IllegalArgumentException(
+                "Cannot merge policies if either policy contains a report-to directive.");
+        }
+
+
         this.resolveSelf();
         other.resolveSelf();
 
diff --git a/src/main/java/com/shapesecurity/salvation/directiveValues/ReportToValue.java b/src/main/java/com/shapesecurity/salvation/directiveValues/ReportToValue.java
new file mode 100644
index 0000000..c4049b5
--- /dev/null
+++ b/src/main/java/com/shapesecurity/salvation/directiveValues/ReportToValue.java
@@ -0,0 +1,26 @@
+package com.shapesecurity.salvation.directiveValues;
+
+import com.shapesecurity.salvation.directives.DirectiveValue;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+public class ReportToValue implements DirectiveValue {
+    @Nonnull private final String value;
+
+    public ReportToValue(@Nonnull String value) {
+        this.value = value;
+    }
+
+    @Nonnull @Override public String show() {
+        return this.value;
+    }
+
+    @Override public int hashCode() {
+        return this.value.hashCode();
+    }
+
+    @Override public boolean equals(@Nullable Object other) {
+        return other instanceof ReportToValue && ((ReportToValue) other).value.equals(this.value);
+    }
+}
diff --git a/src/main/java/com/shapesecurity/salvation/directives/ReportToDirective.java b/src/main/java/com/shapesecurity/salvation/directives/ReportToDirective.java
new file mode 100644
index 0000000..cbedea0
--- /dev/null
+++ b/src/main/java/com/shapesecurity/salvation/directives/ReportToDirective.java
@@ -0,0 +1,19 @@
+package com.shapesecurity.salvation.directives;
+
+import com.shapesecurity.salvation.data.URI;
+import com.shapesecurity.salvation.directiveValues.ReportToValue;
+
+import javax.annotation.Nonnull;
+import java.util.Set;
+
+public class ReportToDirective extends Directive<ReportToValue> {
+    @Nonnull private static final String NAME = "report-to";
+
+    public ReportToDirective(@Nonnull Set<ReportToValue> tokens) {
+        super(ReportToDirective.NAME, tokens);
+    }
+
+    @Nonnull @Override public Directive<ReportToValue> construct(Set<ReportToValue> newValues) {
+        return new ReportToDirective(newValues);
+    }
+}
diff --git a/src/main/java/com/shapesecurity/salvation/tokens/DirectiveNameToken.java b/src/main/java/com/shapesecurity/salvation/tokens/DirectiveNameToken.java
index f96a854..56ed876 100644
--- a/src/main/java/com/shapesecurity/salvation/tokens/DirectiveNameToken.java
+++ b/src/main/java/com/shapesecurity/salvation/tokens/DirectiveNameToken.java
@@ -32,7 +32,8 @@ public class DirectiveNameToken extends Token {
         ObjectSrc,
         PluginTypes,
         Referrer, // in draft at http://www.w3.org/TR/2014/WD-referrer-policy-20140807/#referrer-policy-delivery as of 2015-08-27
-        ReportUri,
+        ReportTo, // CSP3; in draft at http://w3c.github.io/webappsec-csp/#directive-report-to
+        ReportUri, // CSP3 deprecates it
         Sandbox,
         ScriptSrc,
         StyleSrc,
@@ -75,8 +76,8 @@ public class DirectiveNameToken extends Token {
                     return PluginTypes;
                 case "referrer":
                     return Referrer;
-                case "report-uri":
-                    return ReportUri;
+                case "report-to":
+                    return ReportTo;
                 case "sandbox":
                     return Sandbox;
                 case "script-src":
@@ -93,6 +94,8 @@ public class DirectiveNameToken extends Token {
                     return FrameSrc;
                 case "options":
                     return Options;
+                case "report-uri":
+                    return ReportUri;
             }
             return Unrecognised;
         }


Test patch:
diff --git a/src/test/java/com/shapesecurity/salvation/LocationTest.java b/src/test/java/com/shapesecurity/salvation/LocationTest.java
index 21e2375..536354c 100644
--- a/src/test/java/com/shapesecurity/salvation/LocationTest.java
+++ b/src/test/java/com/shapesecurity/salvation/LocationTest.java
@@ -272,6 +272,55 @@ public class LocationTest extends CSPTest {
         assertEquals(27, notices.get(1).endLocation.offset);
     }
 
+    @Test public void testWarningLocationReports() {
+        ArrayList<Notice> notices = new ArrayList<>();
+
+        ParserWithLocation.parse("report-uri /a; report-to b;", "https://origin", notices);
+        assertEquals(1, notices.size());
+        assertNotNull(notices.get(0).startLocation);
+        assertEquals(1, notices.get(0).startLocation.line);
+        assertEquals(1, notices.get(0).startLocation.column);
+        assertEquals(0, notices.get(0).startLocation.offset);
+        assertNotNull(notices.get(0).endLocation);
+        assertEquals(1, notices.get(0).endLocation.line);
+        assertEquals(11, notices.get(0).endLocation.column);
+        assertEquals(10, notices.get(0).endLocation.offset);
+
+        notices.clear();
+        ParserWithLocation.parse("report-uri a /b", "https://origin", notices);
+        assertEquals(2, notices.size());
+        assertNotNull(notices.get(0).startLocation);
+        assertEquals(1, notices.get(0).startLocation.line);
+        assertEquals(1, notices.get(0).startLocation.column);
+        assertEquals(0, notices.get(0).startLocation.offset);
+        assertNotNull(notices.get(0).endLocation);
+        assertEquals(1, notices.get(0).endLocation.line);
+        assertEquals(11, notices.get(0).endLocation.column);
+        assertEquals(10, notices.get(0).endLocation.offset);
+        assertNotNull(notices.get(1).startLocation);
+        assertEquals(1, notices.get(1).startLocation.line);
+        assertEquals(12, notices.get(1).startLocation.column);
+        assertEquals(11, notices.get(1).startLocation.offset);
+        assertNotNull(notices.get(1).endLocation);
+        assertEquals(1, notices.get(1).endLocation.line);
+        assertEquals(13, notices.get(1).endLocation.column);
+        assertEquals(12, notices.get(1).endLocation.offset);
+
+        notices.clear();
+        ParserWithLocation.parse("report-to a ыыы", "https://origin", notices);
+        assertEquals(1, notices.size());
+        assertNotNull(notices.get(0).startLocation);
+        assertEquals(1, notices.get(0).startLocation.line);
+        assertEquals(13, notices.get(0).startLocation.column);
+        assertEquals(12, notices.get(0).startLocation.offset);
+        assertNotNull(notices.get(0).endLocation);
+        assertEquals(1, notices.get(0).endLocation.line);
+        assertEquals(16, notices.get(0).endLocation.column);
+        assertEquals(15, notices.get(0).endLocation.offset);
+
+    }
+
+
     @Test public void testWarningLocationUnsafeRedirect() {
         ArrayList<Notice> notices = new ArrayList<>();
         ParserWithLocation.parse("script-src 'unsafe-redirect'", "https://origin", notices);
diff --git a/src/test/java/com/shapesecurity/salvation/ParserTest.java b/src/test/java/com/shapesecurity/salvation/ParserTest.java
index 151753d..3eeb24d 100644
--- a/src/test/java/com/shapesecurity/salvation/ParserTest.java
+++ b/src/test/java/com/shapesecurity/salvation/ParserTest.java
@@ -303,18 +303,20 @@ public class ParserTest extends CSPTest {
         ArrayList<Notice> notices = new ArrayList<>();
 
         parseWithNotices("report-uri ", notices);
-        assertEquals(1, notices.size());
-        assertEquals("The report-uri directive must contain at least one uri-reference", notices.get(0).message);
+        assertEquals(2, notices.size());
+        assertEquals("A draft of the next version of CSP deprecates report-uri in favour of a new report-to directive.", notices.get(0).message);
+        assertEquals("The report-uri directive must contain at least one uri-reference", notices.get(1).message);
 
         notices.clear();
         parseWithNotices("report-uri #", notices);
-        assertEquals(1, notices.size());
-        assertEquals("Expecting uri-reference but found #", notices.get(0).message);
+        assertEquals(2, notices.size());
+        assertEquals("A draft of the next version of CSP deprecates report-uri in favour of a new report-to directive.", notices.get(0).message);
+        assertEquals("Expecting uri-reference but found #", notices.get(1).message);
 
         notices.clear();
         parseWithNotices("report-uri a", notices);
-        assertEquals(1, notices.size());
-        assertEquals("Expecting uri-reference but found a", notices.get(0).message);
+        assertEquals(2, notices.size());
+        assertEquals("Expecting uri-reference but found a", notices.get(1).message);
 
         Policy p, q;
         p = parse("report-uri http://a");
@@ -337,6 +339,36 @@ public class ParserTest extends CSPTest {
 
     }
 
+    @Test public void testReportTo() {
+        ArrayList<Notice> notices = new ArrayList<>();
+
+        parseWithNotices("report-to ", notices);
+        assertEquals(1, notices.size());
+        assertEquals("The report-to directive must contain exactly one token", notices.get(0).message);
+
+        notices.clear();
+        parseWithNotices("report-to д", notices);
+        assertEquals(2, notices.size());
+        assertEquals("The report-to directive must contain exactly one token", notices.get(0).message);
+        assertEquals("Expecting directive-value but found U+0434 (д). Non-ASCII and non-printable characters must be percent-encoded", notices.get(1).message);
+
+        notices.clear();
+        parseWithNotices("report-to a b", notices);
+        assertEquals(1, notices.size());
+        assertEquals("The report-to directive must contain exactly one token", notices.get(0).message);
+
+        Policy p, q;
+        p = parse("report-to a");
+        q = parse("report-to b");
+        assertNotEquals("report-to hashcode shouldn't match", p.hashCode(), q.hashCode());
+
+        p = parse("report-to        a");
+        q = parse("report-to a; ");
+        assertEquals("report-to hashcode match", p.hashCode(), q.hashCode());
+        assertTrue("report-to equals", p.equals(q));
+
+    }
+
     @Test public void testMediaTypeUnion() {
         Policy p;
         p = parse("plugin-types a/b");
diff --git a/src/test/java/com/shapesecurity/salvation/PolicyMergeTest.java b/src/test/java/com/shapesecurity/salvation/PolicyMergeTest.java
index c86dadd..978969b 100644
--- a/src/test/java/com/shapesecurity/salvation/PolicyMergeTest.java
+++ b/src/test/java/com/shapesecurity/salvation/PolicyMergeTest.java
@@ -142,6 +142,15 @@ public class PolicyMergeTest extends CSPTest {
             assertEquals("Cannot merge policies if either policy contains a report-uri directive.", e1.getMessage());
         }
 
+        p1 = ParserWithLocation.parse("script-src a", URI.parse("https://origin"));
+        p2 = parse("script-src b; report-to abc");
+        try {
+            p1.intersect(p2);
+            fail();
+        } catch (IllegalArgumentException e1) {
+            assertEquals("Cannot merge policies if either policy contains a report-to directive.", e1.getMessage());
+        }
+
         p1 = parse("script-src a");
         p2 = parse("script-src b; report-uri /x");
         try {
@@ -173,6 +182,15 @@ public class PolicyMergeTest extends CSPTest {
         } catch (IllegalArgumentException e1) {
             assertEquals("Cannot merge policies if either policy contains a report-uri directive.", e1.getMessage());
         }
+
+        p1 = parse("script-src a; report-to bbb");
+        p2 = parse("report-to aaa");
+        try {
+            p1.intersect(p2);
+            fail();
+        } catch (IllegalArgumentException e1) {
+            assertEquals("Cannot merge policies if either policy contains a report-to directive.", e1.getMessage());
+        }
     }
 
     @Test public void testNone() {
@@ -234,6 +252,14 @@ public class PolicyMergeTest extends CSPTest {
             assertEquals("Cannot merge policies if either policy contains a report-uri directive.", e1.getMessage());
         }
 
+        try {
+            p1 = parse("script-src a; report-to /a");
+            p2 = parse("script-src b");
+            p1.union(p2);
+        } catch (IllegalArgumentException e1) {
+            assertEquals("Cannot merge policies if either policy contains a report-to directive.", e1.getMessage());
+        }
+
         p1 = parse("default-src a b ");
         p2 = parse("default-src; script-src x; style-src y");
         p1.union(p2);