diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index face63e0..c7884c80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,22 +35,24 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - java: [8, 11] + java: [11] steps: - name: Checkout uses: actions/checkout@v2 - + - uses: actions/setup-node@v4 + with: + node-version: 20 - name: Setup Java uses: actions/setup-java@v2 with: distribution: 'adopt' java-version: ${{ matrix.java }} cache: 'maven' - - name: CI run: | java -Xmx32m -version javac -J-Xmx32m -version mvn install -DskipTests=true -Dmaven.javadoc.skip=true -B -V mvn test -B + - run: node --test diff --git a/README.md b/README.md index ffbf924c..89271cf1 100644 --- a/README.md +++ b/README.md @@ -95,3 +95,17 @@ System.out.println(policy.toString()); ```java policy.toString(); ``` + +## Transpiling to JavaScript +To reduce the overhead of running this library, it will now automatically be transpiled to JS as part of the compile goal by using [TeaVM](https://teavm.org/). It can then be placed on any webpage to be used as static JavaScript, thus alleviating the need for a JRE. + +The transpiled code will be placed in `target/javascript` as `salvation-v${project.version}.min.js`. + +If you experience errors relating to TeaVM transpiling, check the [supported TeaVM classes](https://teavm.org/jcl-report/recent/jcl.html). + +### Using the JavaScript + +First run `mvn clean install` to build the JS file. Then include `salvation-vX.X.X.min.js` in your webpage. +To use the parsing functions in on the webpage run `window.main()` to initialize them. From then on, `window.parseSerializedCSPList()` and `window.parseSerializedCSP()` will be available. + +`parseSerializedCSP()` and `parseSerializedCSPList()` will return strings containing the parsing results. If there are multiple results, they will be separated by a newline. This is simply because TeaVM requires a lot of extra work for it to be able to return JS objects. diff --git a/pom.xml b/pom.xml index dd291356..0e74324e 100644 --- a/pom.xml +++ b/pom.xml @@ -65,6 +65,16 @@ + + org.teavm + teavm-jso + 0.9.2 + + + org.teavm + teavm-jso-apis + 0.9.2 + com.google.code.findbugs jsr305 @@ -81,6 +91,33 @@ + + org.teavm + teavm-maven-plugin + 0.9.2 + + + org.teavm + teavm-classlib + 0.9.2 + + + + + + compile + + process-classes + + com.shapesecurity.salvation2.JSInterface + true + false + false + salvation.min.js + + + + org.sonatype.plugins nexus-staging-maven-plugin diff --git a/src/main/java/com/shapesecurity/salvation2/Constants.java b/src/main/java/com/shapesecurity/salvation2/Constants.java index b47ae339..572e3537 100644 --- a/src/main/java/com/shapesecurity/salvation2/Constants.java +++ b/src/main/java/com/shapesecurity/salvation2/Constants.java @@ -6,14 +6,14 @@ public class Constants { // https://tools.ietf.org/html/rfc3986#section-3.1 public static final String schemePart = "[a-zA-Z][a-zA-Z0-9+\\-.]*"; - public static final Pattern schemePattern = Pattern.compile("^(?" + Constants.schemePart + ":)"); + public static final Pattern schemePattern = Pattern.compile("^(" + Constants.schemePart + ":)"); // https://tools.ietf.org/html/rfc7230#section-3.2.6 public static final Pattern rfc7230TokenPattern = Pattern.compile("^[!#$%&'*+\\-.^_`|~0-9a-zA-Z]+$"); // RFC 2045 appendix A: productions of type and subtype // https://tools.ietf.org/html/rfc2045#section-5.1 - public static final Pattern mediaTypePattern = Pattern.compile("^(?[a-zA-Z0-9!#$%^&*\\-_+{}|'.`~]+)/(?[a-zA-Z0-9!#$%^&*\\-_+{}|'.`~]+)$"); + public static final Pattern mediaTypePattern = Pattern.compile("^([a-zA-Z0-9!#$%^&*\\-_+{}|'.`~]+)/([a-zA-Z0-9!#$%^&*\\-_+{}|'.`~]+)$"); public static final Pattern unquotedKeywordPattern = Pattern.compile("^(?:self|unsafe-inline|unsafe-eval|unsafe-redirect|none|strict-dynamic|unsafe-hashes|report-sample|unsafe-allow-redirects)$"); // port-part constants @@ -37,10 +37,8 @@ public class Constants { private static final String queryFragmentPart = "(?:\\?[^#]*)?(?:#.*)?"; public static final Pattern hostSourcePattern = Pattern.compile( - "^(?" + schemePart + "://)?(?" + hostPart + ")(?" + portPart + ")?(?" + pathPart + "^(" + schemePart + "://)?(" + hostPart + ")(" + portPart + ")?(" + pathPart + ")?" + queryFragmentPart + "$"); - // public static final Pattern relativeReportUriPattern = - // Pattern.compile("^(?" + pathPart + ")" + queryFragmentPart + "$"); public static final Pattern IPv4address = Pattern.compile("^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"); public static final Pattern IPV6loopback = Pattern.compile("^[0:]+:1$"); public static final String IPv6address = "(?:(?:(?:[0-9A-Fa-f]{1,4}:){6}|::(?:[0-9A-Fa-f]{1,4}:){5}|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::)(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)"; diff --git a/src/main/java/com/shapesecurity/salvation2/Directive.java b/src/main/java/com/shapesecurity/salvation2/Directive.java index 9c33df6c..c3b92b36 100644 --- a/src/main/java/com/shapesecurity/salvation2/Directive.java +++ b/src/main/java/com/shapesecurity/salvation2/Directive.java @@ -9,8 +9,10 @@ public class Directive { - public static Predicate IS_DIRECTIVE_NAME = Pattern.compile("^[A-Za-z0-9\\-]+$").asPredicate(); - public static Predicate containsNonDirectiveCharacter = Pattern.compile("[" + Constants.WHITESPACE_CHARS + ",;]").asPredicate(); + private static final Pattern DIRECTIVE_NAME_PATTERN = Pattern.compile("^[A-Za-z0-9\\-]+$"); + public static Predicate IS_DIRECTIVE_NAME = s -> DIRECTIVE_NAME_PATTERN.matcher(s).matches(); + private static final Pattern NON_DIRECTIVE_CHAR_PATTERN = Pattern.compile("[" + Constants.WHITESPACE_CHARS + ",;]"); + public static Predicate containsNonDirectiveCharacter = s -> NON_DIRECTIVE_CHAR_PATTERN.matcher(s).matches(); protected List values; protected static DirectiveErrorConsumer wrapManipulationErrorConsumer(ManipulationErrorConsumer errors) { diff --git a/src/main/java/com/shapesecurity/salvation2/JSInterface.java b/src/main/java/com/shapesecurity/salvation2/JSInterface.java new file mode 100644 index 00000000..ac510a7c --- /dev/null +++ b/src/main/java/com/shapesecurity/salvation2/JSInterface.java @@ -0,0 +1,52 @@ +package com.shapesecurity.salvation2; + +import org.teavm.jso.JSBody; + +public class JSInterface { + public static void main(String[] args) { + initParseList(); + initParseSingle(); + } + + public static String getErrorsForSerializedCSPList(String policyText) { + StringBuilder errorMessages = new StringBuilder(); + Policy.parseSerializedCSPList(policyText, (severity, message, policyIndex, directiveIndex, valueIndex) -> { + errorMessages.append(severity.name()) + .append(" at directive ") + .append(directiveIndex) + .append(valueIndex == -1 ? "" : " at value " + valueIndex) + .append(": ") + .append(message) + .append("\n"); + }); + return errorMessages.toString().trim(); + } + + public static String getErrorsForSerializedCSP(String policyText) { + StringBuilder errorMessages = new StringBuilder(); + + Policy.parseSerializedCSP(policyText, (severity, message, directiveIndex, valueIndex) -> { + errorMessages.append(severity.name()) + .append(" at directive ") + .append(directiveIndex) + .append(valueIndex == -1 ? "" : " at value " + valueIndex) + .append(": ") + .append(message) + .append("\n"); + }); + return errorMessages.toString().trim(); + } + + @JSBody(params = {}, script = + "(window || globalThis).getErrorsForSerializedCSPList = (policyText) => {\n" + + "return javaMethods.get('com.shapesecurity.salvation2.JSInterface.getErrorsForSerializedCSPList(Ljava/lang/String;)Ljava/lang/String;').invoke(policyText)\n" + + "}") + static native void initParseList(); + + @JSBody(params = {}, script = + "(window || globalThis).getErrorsForSerializedCSP = (policyText) => {\n" + + "return javaMethods.get('com.shapesecurity.salvation2.JSInterface.getErrorsForSerializedCSP(Ljava/lang/String;)Ljava/lang/String;').invoke(policyText)\n" + + "}") + static native void initParseSingle(); +} + diff --git a/src/main/java/com/shapesecurity/salvation2/URLs/GUID.java b/src/main/java/com/shapesecurity/salvation2/URLs/GUID.java index 8cf7647d..b3cfac5e 100644 --- a/src/main/java/com/shapesecurity/salvation2/URLs/GUID.java +++ b/src/main/java/com/shapesecurity/salvation2/URLs/GUID.java @@ -17,7 +17,7 @@ public static Optional parseGUID(String value) { if (!matcher.find()) { return Optional.empty(); } - String scheme = matcher.group("scheme"); + String scheme = matcher.group(1); scheme = scheme.substring(0, scheme.length() - 1); // + 1 for the trailing ":" return Optional.of(new GUID(scheme, value.substring(scheme.length() + 1))); } diff --git a/src/main/java/com/shapesecurity/salvation2/URLs/URI.java b/src/main/java/com/shapesecurity/salvation2/URLs/URI.java index 1a32bfe4..da3d2d46 100644 --- a/src/main/java/com/shapesecurity/salvation2/URLs/URI.java +++ b/src/main/java/com/shapesecurity/salvation2/URLs/URI.java @@ -19,20 +19,20 @@ public static Optional parseURI(@Nonnull String uri) { if (!matcher.find()) { return Optional.empty(); } - String scheme = matcher.group("scheme"); + String scheme = matcher.group(1); if (scheme == null) { return Optional.empty(); } scheme = scheme.substring(0, scheme.length() - 3); - String portString = matcher.group("port"); + String portString = matcher.group(3); int port; if (portString == null) { port = URI.defaultPortForProtocol(scheme.toLowerCase(Locale.ENGLISH)); } else { port = portString.equals(":*") ? Constants.WILDCARD_PORT : Integer.parseInt(portString.substring(1)); } - String host = matcher.group("host"); - String path = matcher.group("path"); + String host = matcher.group(2); + String path = matcher.group(4); if (path == null) { path = ""; } diff --git a/src/main/java/com/shapesecurity/salvation2/Utils.java b/src/main/java/com/shapesecurity/salvation2/Utils.java index a876c9e9..00c32713 100644 --- a/src/main/java/com/shapesecurity/salvation2/Utils.java +++ b/src/main/java/com/shapesecurity/salvation2/Utils.java @@ -9,8 +9,8 @@ import java.util.regex.Pattern; public class Utils { - public static final Predicate IS_BASE64_VALUE = Pattern.compile("[a-zA-Z0-9+/\\-_]+=?=?").asPredicate(); - + private static final Pattern BASE64_PATTERN = Pattern.compile("[a-zA-Z0-9+/\\-_]+=?=?"); + public static final Predicate IS_BASE64_VALUE = s -> BASE64_PATTERN.matcher(s).matches(); // https://infra.spec.whatwg.org/#split-on-ascii-whitespace static List splitOnAsciiWhitespace(String input) { ArrayList out = new ArrayList<>(); diff --git a/src/main/java/com/shapesecurity/salvation2/Values/Host.java b/src/main/java/com/shapesecurity/salvation2/Values/Host.java index 596544a7..05838fcd 100644 --- a/src/main/java/com/shapesecurity/salvation2/Values/Host.java +++ b/src/main/java/com/shapesecurity/salvation2/Values/Host.java @@ -31,11 +31,11 @@ private Host(String scheme, String host, int port, String path) { public static Optional parseHost(String value) { Matcher matcher = Constants.hostSourcePattern.matcher(value); if (matcher.find()) { - String scheme = matcher.group("scheme"); + String scheme = matcher.group(1); if (scheme != null) { scheme = scheme.substring(0, scheme.length() - 3).toLowerCase(Locale.ENGLISH); } - String portString = matcher.group("port"); + String portString = matcher.group(3); int port; if (portString == null) { port = Constants.EMPTY_PORT; @@ -43,8 +43,8 @@ public static Optional parseHost(String value) { port = portString.equals(":*") ? Constants.WILDCARD_PORT : Integer.parseInt(portString.substring(1)); } // Hosts are only consumed lowercase: https://w3c.github.io/webappsec-csp/#host-part-match - String host = matcher.group("host").toLowerCase(Locale.ENGLISH); // There is no possible NPE here; host is not optional - String path = matcher.group("path"); + String host = matcher.group(2).toLowerCase(Locale.ENGLISH); // There is no possible NPE here; host is not optional + String path = matcher.group(4); // TODO contemplate warning for paths which contain `//`, `/../`, or `/./`, since those will never match an actual request // TODO contemplate warning for ports which are implied by their scheme diff --git a/src/main/java/com/shapesecurity/salvation2/Values/MediaType.java b/src/main/java/com/shapesecurity/salvation2/Values/MediaType.java index 257860a6..01b0c666 100644 --- a/src/main/java/com/shapesecurity/salvation2/Values/MediaType.java +++ b/src/main/java/com/shapesecurity/salvation2/Values/MediaType.java @@ -24,8 +24,8 @@ public static Optional parseMediaType(String value) { if (matcher.find()) { // plugin type matching is ASCII case-insensitive // https://w3c.github.io/webappsec-csp/#plugin-types-post-request-check - String type = matcher.group("type").toLowerCase(Locale.ENGLISH); - String subtype = matcher.group("subtype").toLowerCase(Locale.ENGLISH); + String type = matcher.group(1).toLowerCase(Locale.ENGLISH); + String subtype = matcher.group(2).toLowerCase(Locale.ENGLISH); return Optional.of(new MediaType(type, subtype)); } return Optional.empty(); diff --git a/src/test/javascript/salvation.test.js b/src/test/javascript/salvation.test.js new file mode 100644 index 00000000..6f1cee61 --- /dev/null +++ b/src/test/javascript/salvation.test.js @@ -0,0 +1,36 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert'); +const salvation = require('../../../target/javascript/salvation.min.js'); + +test('salvation initialization', () => { + assert.notStrictEqual(salvation.main, undefined); + salvation.main(); + assert.notStrictEqual(getErrorsForSerializedCSP, undefined); + assert.notStrictEqual(getErrorsForSerializedCSPList, undefined); +}); + +test('.getErrorsForSerializedCSP() gives no errors for a valid CSP', () => { + salvation.main(); + const result = getErrorsForSerializedCSP('default-src \'none\';'); + assert.strictEqual(result.length, 0, 'No errors should be found'); +}); + +test('.getErrorsForSerializedCSP() provides feedback', () => { + salvation.main(); + const result = getErrorsForSerializedCSP('hello world'); + assert.strictEqual(result, 'Warning at directive 0: Unrecognized directive hello'); +}); + +test('.getErrorsForSerializedCSPList() gives no errors for a valid CSP', () => { + salvation.main(); + const result = getErrorsForSerializedCSPList('default-src \'none\',plugin-types image/png application/pdf; sandbox,style-src https: \'self\''); + assert.strictEqual(result, ''); +}); + +test('.getErrorsForSerializedCSPList() provides feedback', () => { + salvation.main(); + const result = getErrorsForSerializedCSPList('hello,foobar,script-src \'self\'; style-src \'self\''); + assert.strictEqual(result, 'Warning at directive 0: Unrecognized directive hello\n' + + 'Warning at directive 0: Unrecognized directive foobar'); +});