diff --git a/src/main/java/org/owasp/validator/css/CssHandler.java b/src/main/java/org/owasp/validator/css/CssHandler.java index 7be3875..5bcca30 100644 --- a/src/main/java/org/owasp/validator/css/CssHandler.java +++ b/src/main/java/org/owasp/validator/css/CssHandler.java @@ -35,6 +35,10 @@ import java.util.LinkedList; import java.util.List; import java.util.ResourceBundle; +import org.owasp.validator.css.media.CssMediaFeature; +import org.owasp.validator.css.media.CssMediaQuery; +import org.owasp.validator.css.media.CssMediaQueryList; +import org.owasp.validator.css.media.CssMediaType; import org.owasp.validator.html.InternalPolicy; import org.owasp.validator.html.Policy; import org.owasp.validator.html.ScanException; @@ -96,6 +100,10 @@ public class CssHandler implements DocumentHandler { */ private boolean selectorOpen = false; + private MediaState mediaState = MediaState.OUTSIDE; + + private enum MediaState {INSIDE, OUTSIDE, DENIED} + /** * Constructs a handler for stylesheets using the given policy. The List of embedded stylesheets * produced by this constructor is now available via the getImportedStylesheetsURIList() method. @@ -340,26 +348,6 @@ public void endFontFace() throws CSSException { // CSS2 Font Face declaration - ignore this for now } - /* - * (non-Javadoc) - * - * @see org.w3c.css.sac.DocumentHandler#startMedia(org.w3c.css.sac.SACMediaList) - */ - @Override - public void startMedia(SACMediaList media) throws CSSException { - // CSS2 Media declaration - ignore this for now - } - - /* - * (non-Javadoc) - * - * @see org.w3c.css.sac.DocumentHandler#endMedia(org.w3c.css.sac.SACMediaList) - */ - @Override - public void endMedia(SACMediaList media) throws CSSException { - // CSS2 Media declaration - ignore this for now - } - /* * (non-Javadoc) * @@ -538,4 +526,56 @@ public void property(String name, LexicalUnit value, boolean important) throws C })); } } + + @Override + public void startMedia(SACMediaList media) throws CSSException { + CssMediaQueryList mediaQueryList = (CssMediaQueryList) media; + + boolean first = true; + for (CssMediaQuery query : mediaQueryList.getMediaQueries()) { + if (!validator.isValidMediaQuery(query)) { + continue; + } + if (first) { + styleSheet.append("@media "); + first = false; + } else { + styleSheet.append(", "); + } + if (query.getLogicalOperator() != null) { + styleSheet.append(query.getLogicalOperator()).append(' '); + } + styleSheet.append(query.getMediaType()); + for (CssMediaFeature feature : query.getMediaFeatures()) { + if (feature == query.getMediaFeatures().get(0) && query.getMediaType() == CssMediaType.IMPLIED_ALL) { + styleSheet.append("("); + } else { + styleSheet.append(" and ("); + } + styleSheet.append(feature.getName()); + if (feature.getExpression() != null) { + styleSheet.append(": "); + styleSheet.append(validator.lexicalValueToString(feature.getExpression())); + } + styleSheet.append(')'); + } + } + + if (!first) { + styleSheet.append(" {"); + styleSheet.append('\n'); + mediaState = MediaState.INSIDE; + } else { + mediaState = MediaState.DENIED; + } + } + + @Override + public void endMedia(SACMediaList media) throws CSSException { + if (mediaState == MediaState.INSIDE) { + styleSheet.append('}'); + styleSheet.append('\n'); + } + mediaState = MediaState.OUTSIDE; + } } diff --git a/src/main/java/org/owasp/validator/css/CssParser.java b/src/main/java/org/owasp/validator/css/CssParser.java index 832f6c9..590f6ea 100644 --- a/src/main/java/org/owasp/validator/css/CssParser.java +++ b/src/main/java/org/owasp/validator/css/CssParser.java @@ -28,7 +28,16 @@ */ package org.owasp.validator.css; +import static org.owasp.validator.css.media.CssMediaQueryLogicalOperator.AND; +import static org.owasp.validator.css.media.CssMediaQueryLogicalOperator.OR; + +import org.apache.batik.css.parser.CSSSACMediaList; import org.apache.batik.css.parser.LexicalUnits; +import org.owasp.validator.css.media.CssMediaFeature; +import org.owasp.validator.css.media.CssMediaQuery; +import org.owasp.validator.css.media.CssMediaQueryList; +import org.owasp.validator.css.media.CssMediaQueryLogicalOperator; +import org.owasp.validator.css.media.CssMediaType; import org.w3c.css.sac.CSSException; import org.w3c.css.sac.CSSParseException; import org.w3c.css.sac.LexicalUnit; @@ -96,4 +105,149 @@ protected void parseStyleDeclaration(final boolean inSheet) throws CSSException } } } + + @Override + protected CSSSACMediaList parseMediaList() { + CssMediaQueryList mediaList = new CssMediaQueryList(); + + mediaList.append(parseMediaQuery()); + while (hasAnotherMediaQuery()) { + nextIgnoreSpaces(); + mediaList.append(parseMediaQuery()); + } + + return mediaList; + } + + private boolean hasAnotherMediaQuery() { + return current == LexicalUnits.COMMA || (current == LexicalUnits.IDENTIFIER && scanner.getStringValue().equals(OR.toString())); + } + + protected CssMediaQuery parseMediaQuery() { + CssMediaQuery query = new CssMediaQuery(); + CssMediaType mediaType = null; + CssMediaQueryLogicalOperator logicalOperator = null; + switch (current) { + case LexicalUnits.LEFT_BRACE: + mediaType = CssMediaType.IMPLIED_ALL; + break; + case LexicalUnits.IDENTIFIER: + logicalOperator = CssMediaQueryLogicalOperator.parse(scanner.getStringValue()); + if (logicalOperator != null) { + switch (logicalOperator) { + case ONLY: + parseLogicalOperatorOnly(); + mediaType = parseMediaType(); + break; + case NOT: + mediaType = parseLogicalOperatorNot(); + break; + case AND: + case OR: + case COMMA: + throw createCSSParseException("identifier"); + } + } else { + mediaType = parseMediaType(); + } + break; + default: + throw createCSSParseException("identifier"); + } + query.setMediaType(mediaType); + query.setLogicalOperator(logicalOperator); + + if (mediaType == CssMediaType.IMPLIED_ALL) { + query.addMediaFeature(parseMediaFeature()); + } + + while (current == LexicalUnits.IDENTIFIER && CssMediaQueryLogicalOperator.parse(scanner.getStringValue()) == AND) { + nextIgnoreSpaces(); + query.addMediaFeature(parseMediaFeature()); + } + return query; + } + + private CssMediaType parseMediaType() { + CssMediaType mediaType; + mediaType = CssMediaType.parse(scanner.getStringValue()); + if (mediaType == null) { + throw createCSSParseException("identifier"); + } + nextIgnoreSpaces(); + return mediaType; + } + + private CssMediaType parseLogicalOperatorNot() { + CssMediaType mediaType; + if (nextIgnoreSpaces() == LexicalUnits.IDENTIFIER) { + mediaType = parseMediaType(); + } else { + mediaType = CssMediaType.IMPLIED_ALL; + } + return mediaType; + } + + private void parseLogicalOperatorOnly() { + if (nextIgnoreSpaces() != LexicalUnits.IDENTIFIER) { + throw createCSSParseException("identifier"); + } + } + + protected CssMediaFeature parseMediaFeature() { + if (current != LexicalUnits.LEFT_BRACE) { + throw createCSSParseException("'(' expected."); + } + nextIgnoreSpaces(); + String namePrefix = ""; + if (current == LexicalUnits.MINUS) { + nextIgnoreSpaces(); + namePrefix = "-"; + } + if (current != LexicalUnits.IDENTIFIER) { + throw createCSSParseException("identifier"); + } + String name = namePrefix + scanner.getStringValue(); + nextIgnoreSpaces(); + LexicalUnit exp = null; + if (current == LexicalUnits.COLON) { + nextIgnoreSpaces(); + exp = parseTerm(null); + } + if (current != LexicalUnits.RIGHT_BRACE) { + throw createCSSParseException("')' expected."); + } + nextIgnoreSpaces(); + + return new CssMediaFeature(name, exp); + } + + @Override + protected void parseMediaRule() { + CSSSACMediaList ml = parseMediaList(); + try { + documentHandler.startMedia(ml); + + if (current != LexicalUnits.LEFT_CURLY_BRACE) { + reportError("left.curly.brace"); + } else { + nextIgnoreSpaces(); + + loop: + for (; ; ) { + switch (current) { + case LexicalUnits.EOF: + case LexicalUnits.RIGHT_CURLY_BRACE: + break loop; + default: + parseRuleSet(); + } + } + + nextIgnoreSpaces(); + } + } finally { + documentHandler.endMedia(ml); + } + } } diff --git a/src/main/java/org/owasp/validator/css/CssValidator.java b/src/main/java/org/owasp/validator/css/CssValidator.java index d9547ba..8f6c07b 100644 --- a/src/main/java/org/owasp/validator/css/CssValidator.java +++ b/src/main/java/org/owasp/validator/css/CssValidator.java @@ -31,6 +31,9 @@ import java.text.DecimalFormat; import java.util.Iterator; import java.util.regex.Pattern; +import org.apache.batik.css.parser.CSSLexicalUnit; +import org.owasp.validator.css.media.CssMediaFeature; +import org.owasp.validator.css.media.CssMediaQuery; import org.owasp.validator.html.Policy; import org.owasp.validator.html.ScanException; import org.owasp.validator.html.model.AntiSamyPattern; @@ -56,6 +59,8 @@ */ public class CssValidator { + private static final LexicalUnit EMPTYSTRINGLEXICALUNIT = CSSLexicalUnit.createString(LexicalUnit.SAC_STRING_VALUE, "", null); + private final Policy policy; /** @@ -406,6 +411,45 @@ public String lexicalValueToString(LexicalUnit lu) { } } + /** + * Returns whether the given {@link CssMediaQuery} is valid + * + * @param mediaQuery mediaQuery + * @return valid mediaQuery? + */ + public boolean isValidMediaQuery(CssMediaQuery mediaQuery) { + // check mediaType against allowed media-HTML-Attribute + Property mediatype = policy.getPropertyByName("_mediatype"); + if (mediatype == null) { + return false; + } + + String mediaTypeString = mediaQuery.getMediaType().toString().toLowerCase(); + boolean isRegExpAllowed = false; + for (Pattern pattern : mediatype.getAllowedRegExp()) { + isRegExpAllowed |= pattern.matcher(mediaTypeString).matches(); + } + boolean isValidMediaType = mediatype.getAllowedValues().contains(mediaTypeString) || isRegExpAllowed; + if (!isValidMediaType) { + return false; + } + + for (CssMediaFeature feature : mediaQuery.getMediaFeatures()) { + LexicalUnit expression = feature.getExpression(); + if (expression == null) { + expression = EMPTYSTRINGLEXICALUNIT; + } + if (!isValidMediaFeature(feature.getName(), expression)) { + return false; + } + } + return true; + } + + private boolean isValidMediaFeature(String name, LexicalUnit lu) { + return isValidProperty("_mediafeature_" + name, lu); + } + /** * Returns color value as int. * Maps percentages to values between 0 and 255. diff --git a/src/main/java/org/owasp/validator/css/media/CssMediaFeature.java b/src/main/java/org/owasp/validator/css/media/CssMediaFeature.java new file mode 100644 index 0000000..f4a34c5 --- /dev/null +++ b/src/main/java/org/owasp/validator/css/media/CssMediaFeature.java @@ -0,0 +1,28 @@ +package org.owasp.validator.css.media; + +import org.w3c.css.sac.LexicalUnit; + +public class CssMediaFeature { + + private final String name; + private final LexicalUnit expression; + + /** + * Constructor. + * + * @param name Feature-name + * @param expression expression, may be null + */ + public CssMediaFeature(String name, LexicalUnit expression) { + this.name = name; + this.expression = expression; + } + + public String getName() { + return name; + } + + public LexicalUnit getExpression() { + return expression; + } +} diff --git a/src/main/java/org/owasp/validator/css/media/CssMediaQuery.java b/src/main/java/org/owasp/validator/css/media/CssMediaQuery.java new file mode 100644 index 0000000..6005200 --- /dev/null +++ b/src/main/java/org/owasp/validator/css/media/CssMediaQuery.java @@ -0,0 +1,36 @@ +package org.owasp.validator.css.media; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class CssMediaQuery { + + private CssMediaQueryLogicalOperator logicalOperator; + private CssMediaType mediaType; + private final List mediaFeatures = new ArrayList<>(); + + public CssMediaQueryLogicalOperator getLogicalOperator() { + return logicalOperator; + } + + public void setLogicalOperator(CssMediaQueryLogicalOperator logicalOperator) { + this.logicalOperator = logicalOperator; + } + + public CssMediaType getMediaType() { + return mediaType; + } + + public void setMediaType(CssMediaType mediaType) { + this.mediaType = mediaType; + } + + public void addMediaFeature(CssMediaFeature mediaFeature) { + mediaFeatures.add(mediaFeature); + } + + public List getMediaFeatures() { + return Collections.unmodifiableList(mediaFeatures); + } +} diff --git a/src/main/java/org/owasp/validator/css/media/CssMediaQueryList.java b/src/main/java/org/owasp/validator/css/media/CssMediaQueryList.java new file mode 100644 index 0000000..52fe815 --- /dev/null +++ b/src/main/java/org/owasp/validator/css/media/CssMediaQueryList.java @@ -0,0 +1,44 @@ +package org.owasp.validator.css.media; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.apache.batik.css.parser.CSSSACMediaList; + +public class CssMediaQueryList extends CSSSACMediaList { + + private final List mediaQueries = new ArrayList<>(); + + @Override + public int getLength() { + return mediaQueries.size(); + } + + /** + * Use {@link CssMediaQueryList#getMediaQueryAt(int)} instead. + * {@inheritDoc} + */ + @Override + public String item(int index) { + CssMediaQuery query = getMediaQueryAt(index); + if (query.getMediaFeatures().isEmpty() && query.getLogicalOperator() == null) { + return query.getMediaType().toString(); + } else { + throw new UnsupportedOperationException("CSS3 MediaQuery unsupported"); + } + } + + public CssMediaQuery getMediaQueryAt(int index) { + return mediaQueries.get(index); + } + + public List getMediaQueries() { + return Collections.unmodifiableList(mediaQueries); + } + + + public void append(CssMediaQuery mediaQuery) { + mediaQueries.add(mediaQuery); + } +} diff --git a/src/main/java/org/owasp/validator/css/media/CssMediaQueryLogicalOperator.java b/src/main/java/org/owasp/validator/css/media/CssMediaQueryLogicalOperator.java new file mode 100644 index 0000000..58d1d60 --- /dev/null +++ b/src/main/java/org/owasp/validator/css/media/CssMediaQueryLogicalOperator.java @@ -0,0 +1,25 @@ +package org.owasp.validator.css.media; + +public enum CssMediaQueryLogicalOperator { + AND("and"), NOT("not"), ONLY("only"), OR("or"), COMMA(","); + + public final String label; + + CssMediaQueryLogicalOperator(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } + + public static CssMediaQueryLogicalOperator parse(String operator) { + for (CssMediaQueryLogicalOperator element : values()) { + if (element.label.equalsIgnoreCase(operator)) { + return element; + } + } + return null; + } +} diff --git a/src/main/java/org/owasp/validator/css/media/CssMediaType.java b/src/main/java/org/owasp/validator/css/media/CssMediaType.java new file mode 100644 index 0000000..8f70a84 --- /dev/null +++ b/src/main/java/org/owasp/validator/css/media/CssMediaType.java @@ -0,0 +1,25 @@ +package org.owasp.validator.css.media; + +public enum CssMediaType { + ALL("all"), PRINT("print"), SCREEN("screen"), IMPLIED_ALL(""); + + public final String label; + + CssMediaType(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } + + public static CssMediaType parse(String mediaType) { + for (CssMediaType element : values()) { + if (element.toString().equalsIgnoreCase(mediaType)) { + return element; + } + } + return null; + } +} diff --git a/src/main/resources/antisamy-anythinggoes.xml b/src/main/resources/antisamy-anythinggoes.xml index 1c3b330..52a39a2 100644 --- a/src/main/resources/antisamy-anythinggoes.xml +++ b/src/main/resources/antisamy-anythinggoes.xml @@ -97,6 +97,8 @@ + + @@ -2612,6 +2614,479 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/org/owasp/validator/html/test/AntiSamyTest.java b/src/test/java/org/owasp/validator/html/test/AntiSamyTest.java index 4699c24..0dcf7a2 100644 --- a/src/test/java/org/owasp/validator/html/test/AntiSamyTest.java +++ b/src/test/java/org/owasp/validator/html/test/AntiSamyTest.java @@ -34,6 +34,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -67,6 +68,7 @@ import org.owasp.validator.html.model.Property; import org.owasp.validator.html.model.Tag; import org.owasp.validator.html.scan.Constants; +import org.w3c.css.sac.CSSParseException; /** * This class tests AntiSamy functionality and the basic policy file which should be immune to XSS @@ -2755,4 +2757,129 @@ public void testGithubIssue546FaultyPercentagesGetFilteredByRegex() throws ScanE assertEquals(expectedCleanHtml, crDom.getCleanHTML()); assertEquals(expectedCleanHtml, crSax.getCleanHTML()); } + + @Test + public void testGithubIssue552() throws ScanException, PolicyException { + Pattern positiveLength = Pattern.compile("((\\+)?0|(\\+)?([0-9]+(\\.[0-9]+)?([eE][+-]?[0-9]+)?)(rem|vw|vh|em|ex|px|in|cm|mm|pt|pc))"); + Pattern integer = Pattern.compile("([-+])?[0-9]+"); + Property mediaType = new Property("_mediatype", + Collections.emptyList(), + Arrays.asList("", "all", "print", "screen"), + Collections.emptyList(), + "", + "remove"); + Property minWidth = new Property("_mediafeature_min-width", + Collections.singletonList(positiveLength), + Collections.emptyList(), + Collections.emptyList(), + "", + "remove"); + Property maxWidth = new Property("_mediafeature_max-width", + Collections.singletonList(positiveLength), + Collections.emptyList(), + Collections.emptyList(), + "", + "remove"); + Property color = new Property("_mediafeature_color", + Collections.singletonList(integer), + Collections.singletonList(""), + Collections.emptyList(), + "", + "remove"); + Property orientation = new Property("_mediafeature_orientation", + Collections.emptyList(), + Arrays.asList("portrait", "landscape"), + Collections.emptyList(), + "", + "remove"); + Property grid = new Property("_mediafeature_grid", + Collections.emptyList(), + Arrays.asList("", "-1", "-0", "0", "1"), + Collections.emptyList(), + "", + "remove"); + Property monochrome = new Property("_mediafeature_monochrome", + Collections.singletonList(integer), + Collections.singletonList(""), + Collections.emptyList(), + "", + "remove"); + + checkStyleTag("@media screen {}", + "@media screen {\n}\n", + policy.addCssProperty(mediaType)); + + checkStyleTag("@media screen,print {}", + "@media screen, print {\n}\n", + policy.addCssProperty(mediaType)); + + checkStyleTag("@media only screen and (max-width: 639px) and (min-width: 300px) {}", + "@media only screen and (max-width: 639.0px) and (min-width: 300.0px) {\n}\n", + policy.addCssProperty(mediaType).addCssProperty(minWidth).addCssProperty(maxWidth)); + + checkStyleTag("@media not screen, screen and (color), print and (orientation: portrait) {}", + "@media not screen, screen and (color), print and (orientation: portrait) {\n}\n", + policy.addCssProperty(mediaType).addCssProperty(color).addCssProperty(orientation)); + + checkStyleTag("@media not screen, print and (orientation: doesNotExist), all {}", + "@media not screen, all {\n}\n", + policy.addCssProperty(mediaType).addCssProperty(orientation)); + + checkStyleTag("@media (min-width: 500.0px) {}", + "@media (min-width: 500.0px) {\n}\n", + policy.addCssProperty(mediaType).addCssProperty(minWidth)); + + checkStyleTag("@media (grid) {}", + "@media (grid) {\n}\n", + policy.addCssProperty(mediaType).addCssProperty(grid)); + + checkStyleTag("@media (monochrome) {}", + "@media (monochrome) {\n}\n", + policy.addCssProperty(mediaType).addCssProperty(monochrome)); + + checkStyleTag("@media (monochrome: 2) {}", + "@media (monochrome: 2) {\n}\n", + policy.addCssProperty(mediaType).addCssProperty(monochrome)); + + checkStyleTag("@media screen and (max-width: 639px) or only print {}", + "@media screen and (max-width: 639.0px), only print {\n}\n", + policy.addCssProperty(mediaType).addCssProperty(maxWidth)); + + checkStyleTag("@media print or (max-width: 639px) or only print {}", + "@media print, (max-width: 639.0px), only print {\n}\n", + policy.addCssProperty(mediaType).addCssProperty(maxWidth)); + + checkStyleTag("@media print or not (max-width: 639px), not print {}", + "@media print, not (max-width: 639.0px), not print {\n}\n", + policy.addCssProperty(mediaType).addCssProperty(maxWidth)); + + checkStyleTag("@media only print or not (max-width: 639px) and (min-width: 500px), not print {}", + "@media only print, not (max-width: 639.0px) and (min-width: 500.0px), not print {\n}\n", + policy.addCssProperty(mediaType).addCssProperty(maxWidth).addCssProperty(minWidth)); + + checkStyleTag("@media only print or not (max-width: 639px) and (min-width: 500px), not screen {}", + "@media only print, not screen {\n}\n", + policy.addCssProperty(mediaType).addCssProperty(maxWidth)); + + checkStyleTag("@media screen {*{color: red;notAllowed: nope}}", + "@media screen {\n* {\n\tcolor: red;\n}\n}\n", + policy.addCssProperty(mediaType)); + + assertThrows(CSSParseException.class, () -> checkStyleTag("@media notValid screen {}", "", policy)); + assertThrows(CSSParseException.class, () -> checkStyleTag("@media doesNotExist {}", "", policy)); + } + + private void checkStyleTag(String input, String expected, Policy policy) throws ScanException, PolicyException { + //Given + String taintedHtml = ""; + String expectedCleanHtml = ""; + + //When + CleanResults crDom = as.scan(taintedHtml, policy, AntiSamy.DOM); + CleanResults crSax = as.scan(taintedHtml, policy, AntiSamy.SAX); + + //Then + assertEquals(expectedCleanHtml, crDom.getCleanHTML()); + assertEquals(expectedCleanHtml, crSax.getCleanHTML()); + } }