diff --git a/src/main/java/graphql/parser/ParseCancelledTooDeepException.java b/src/main/java/graphql/parser/ParseCancelledTooDeepException.java new file mode 100644 index 0000000000..f872fd1ac1 --- /dev/null +++ b/src/main/java/graphql/parser/ParseCancelledTooDeepException.java @@ -0,0 +1,15 @@ +package graphql.parser; + +import graphql.Internal; +import graphql.language.SourceLocation; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@Internal +public class ParseCancelledTooDeepException extends InvalidSyntaxException { + + @Internal + public ParseCancelledTooDeepException(String msg, @Nullable SourceLocation sourceLocation, @Nullable String offendingToken, int maxTokens, @NotNull String tokenType) { + super(sourceLocation, msg, null, offendingToken, null); + } +} diff --git a/src/main/java/graphql/parser/Parser.java b/src/main/java/graphql/parser/Parser.java index d2726dda68..0b0d7821c4 100644 --- a/src/main/java/graphql/parser/Parser.java +++ b/src/main/java/graphql/parser/Parser.java @@ -236,7 +236,11 @@ public void syntaxError(Recognizer recognizer, Object offendingSymbol, int // this lexer wrapper allows us to stop lexing when too many tokens are in place. This prevents DOS attacks. int maxTokens = parserOptions.getMaxTokens(); int maxWhitespaceTokens = parserOptions.getMaxWhitespaceTokens(); - BiConsumer onTooManyTokens = (maxTokenCount, token) -> throwCancelParseIfTooManyTokens(token, maxTokenCount, multiSourceReader); + BiConsumer onTooManyTokens = (maxTokenCount, token) -> throwIfTokenProblems( + token, + maxTokenCount, + multiSourceReader, + ParseCancelledException.class); SafeTokenSource safeTokenSource = new SafeTokenSource(lexer, maxTokens, maxWhitespaceTokens, onTooManyTokens); CommonTokenStream tokens = new CommonTokenStream(safeTokenSource); @@ -285,9 +289,30 @@ private void setupParserListener(MultiSourceReader multiSourceReader, GraphqlPar ParserOptions parserOptions = toLanguage.getParserOptions(); ParsingListener parsingListener = parserOptions.getParsingListener(); int maxTokens = parserOptions.getMaxTokens(); + int maxRuleDepth = parserOptions.getMaxRuleDepth(); // prevent a billion laugh attacks by restricting how many tokens we allow ParseTreeListener listener = new GraphqlBaseListener() { int count = 0; + int depth = 0; + + + @Override + public void enterEveryRule(ParserRuleContext ctx) { + depth++; + if (depth > maxRuleDepth) { + throwIfTokenProblems( + ctx.getStart(), + maxRuleDepth, + multiSourceReader, + ParseCancelledTooDeepException.class + ); + } + } + + @Override + public void exitEveryRule(ParserRuleContext ctx) { + depth--; + } @Override public void visitTerminal(TerminalNode node) { @@ -312,15 +337,20 @@ public int getCharPositionInLine() { count++; if (count > maxTokens) { - throwCancelParseIfTooManyTokens(token, maxTokens, multiSourceReader); + throwIfTokenProblems( + token, + maxTokens, + multiSourceReader, + ParseCancelledException.class + ); } } }; parser.addParseListener(listener); } - private void throwCancelParseIfTooManyTokens(Token token, int maxTokens, MultiSourceReader multiSourceReader) throws ParseCancelledException { - String tokenType = "grammar"; + private void throwIfTokenProblems(Token token, int maxLimit, MultiSourceReader multiSourceReader, Class targetException) throws ParseCancelledException { + String tokenType = "grammar"; SourceLocation sourceLocation = null; String offendingToken = null; if (token != null) { @@ -330,9 +360,12 @@ private void throwCancelParseIfTooManyTokens(Token token, int maxTokens, MultiSo offendingToken = token.getText(); sourceLocation = AntlrHelper.createSourceLocation(multiSourceReader, token.getLine(), token.getCharPositionInLine()); } - String msg = String.format("More than %d %s tokens have been presented. To prevent Denial Of Service attacks, parsing has been cancelled.", maxTokens, tokenType); - throw new ParseCancelledException(msg, sourceLocation, offendingToken); - } + if (targetException.equals(ParseCancelledTooDeepException.class)) { + String msg = String.format("More than %d deep %s rules have been entered. To prevent Denial Of Service attacks, parsing has been cancelled.", maxLimit, tokenType); + throw new ParseCancelledTooDeepException(msg, sourceLocation, offendingToken, maxLimit, tokenType); + } + String msg = String.format("More than %d %s tokens have been presented. To prevent Denial Of Service attacks, parsing has been cancelled.", maxLimit, tokenType); + throw new ParseCancelledException(msg, sourceLocation, offendingToken); } /** * Allows you to override the ANTLR to AST code. diff --git a/src/main/java/graphql/parser/ParserOptions.java b/src/main/java/graphql/parser/ParserOptions.java index 6fe708323a..00ea843755 100644 --- a/src/main/java/graphql/parser/ParserOptions.java +++ b/src/main/java/graphql/parser/ParserOptions.java @@ -15,9 +15,9 @@ public class ParserOptions { /** * A graphql hacking vector is to send nonsensical queries that burn lots of parsing CPU time and burn * memory representing a document that won't ever execute. To prevent this for most users, graphql-java - * set this value to 15000. ANTLR parsing time is linear to the number of tokens presented. The more you + * sets this value to 15000. ANTLR parsing time is linear to the number of tokens presented. The more you * allow the longer it takes. - * + *

* If you want to allow more, then {@link #setDefaultParserOptions(ParserOptions)} allows you to change this * JVM wide. */ @@ -26,18 +26,29 @@ public class ParserOptions { * Another graphql hacking vector is to send large amounts of whitespace in operations that burn lots of parsing CPU time and burn * memory representing a document. Whitespace token processing in ANTLR is 2 orders of magnitude faster than grammar token processing * however it still takes some time to happen. - * + *

* If you want to allow more, then {@link #setDefaultParserOptions(ParserOptions)} allows you to change this * JVM wide. */ public static final int MAX_WHITESPACE_TOKENS = 200_000; + /** + * A graphql hacking vector is to send nonsensical queries that have lots of grammar rule depth to them which + * can cause stack overflow exceptions during the query parsing. To prevent this for most users, graphql-java + * sets this value to 500 grammar rules deep. + *

+ * If you want to allow more, then {@link #setDefaultParserOptions(ParserOptions)} allows you to change this + * JVM wide. + */ + public static final int MAX_RULE_DEPTH = 500; + private static ParserOptions defaultJvmParserOptions = newParserOptions() .captureIgnoredChars(false) .captureSourceLocation(true) .captureLineComments(true) .maxTokens(MAX_QUERY_TOKENS) // to prevent a billion laughs style attacks, we set a default for graphql-java .maxWhitespaceTokens(MAX_WHITESPACE_TOKENS) + .maxRuleDepth(MAX_RULE_DEPTH) .build(); private static ParserOptions defaultJvmOperationParserOptions = newParserOptions() @@ -46,6 +57,7 @@ public class ParserOptions { .captureLineComments(false) // #comments are not useful in query parsing .maxTokens(MAX_QUERY_TOKENS) // to prevent a billion laughs style attacks, we set a default for graphql-java .maxWhitespaceTokens(MAX_WHITESPACE_TOKENS) + .maxRuleDepth(MAX_RULE_DEPTH) .build(); private static ParserOptions defaultJvmSdlParserOptions = newParserOptions() @@ -54,6 +66,7 @@ public class ParserOptions { .captureLineComments(true) // #comments are useful in SDL parsing .maxTokens(Integer.MAX_VALUE) // we are less worried about a billion laughs with SDL parsing since the call path is not facing attackers .maxWhitespaceTokens(Integer.MAX_VALUE) + .maxRuleDepth(Integer.MAX_VALUE) .build(); /** @@ -156,6 +169,7 @@ public static void setDefaultSdlParserOptions(ParserOptions options) { private final boolean captureLineComments; private final int maxTokens; private final int maxWhitespaceTokens; + private final int maxRuleDepth; private final ParsingListener parsingListener; private ParserOptions(Builder builder) { @@ -164,6 +178,7 @@ private ParserOptions(Builder builder) { this.captureLineComments = builder.captureLineComments; this.maxTokens = builder.maxTokens; this.maxWhitespaceTokens = builder.maxWhitespaceTokens; + this.maxRuleDepth = builder.maxRuleDepth; this.parsingListener = builder.parsingListener; } @@ -226,6 +241,17 @@ public int getMaxWhitespaceTokens() { return maxWhitespaceTokens; } + /** + * A graphql hacking vector is to send nonsensical queries that have lots of rule depth to them which + * can cause stack overflow exceptions during the query parsing. To prevent this you can set a value + * that is the maximum depth allowed before an exception is thrown and the parsing is stopped. + * + * @return the maximum token depth the parser will accept, after which an exception will be thrown. + */ + public int getMaxRuleDepth() { + return maxRuleDepth; + } + public ParsingListener getParsingListener() { return parsingListener; } @@ -245,9 +271,10 @@ public static class Builder { private boolean captureIgnoredChars = false; private boolean captureSourceLocation = true; private boolean captureLineComments = true; - private int maxTokens = MAX_QUERY_TOKENS; private ParsingListener parsingListener = ParsingListener.NOOP; + private int maxTokens = MAX_QUERY_TOKENS; private int maxWhitespaceTokens = MAX_WHITESPACE_TOKENS; + private int maxRuleDepth = MAX_RULE_DEPTH; Builder() { } @@ -258,6 +285,7 @@ public static class Builder { this.captureLineComments = parserOptions.captureLineComments; this.maxTokens = parserOptions.maxTokens; this.maxWhitespaceTokens = parserOptions.maxWhitespaceTokens; + this.maxRuleDepth = parserOptions.maxRuleDepth; this.parsingListener = parserOptions.parsingListener; } @@ -286,6 +314,11 @@ public Builder maxWhitespaceTokens(int maxWhitespaceTokens) { return this; } + public Builder maxRuleDepth(int maxRuleDepth) { + this.maxRuleDepth = maxRuleDepth; + return this; + } + public Builder parsingListener(ParsingListener parsingListener) { this.parsingListener = assertNotNull(parsingListener); return this; diff --git a/src/test/groovy/graphql/parser/ParserStressTest.groovy b/src/test/groovy/graphql/parser/ParserStressTest.groovy new file mode 100644 index 0000000000..9eeade2eac --- /dev/null +++ b/src/test/groovy/graphql/parser/ParserStressTest.groovy @@ -0,0 +1,176 @@ +package graphql.parser + +import graphql.ExecutionInput +import graphql.TestUtil +import spock.lang.Specification + +/** + * Tests related to how the Parser can be stress tested + */ +class ParserStressTest extends Specification { + static defaultOptions = ParserOptions.getDefaultParserOptions() + static defaultOperationOptions = ParserOptions.getDefaultOperationParserOptions() + static defaultSdlOptions = ParserOptions.getDefaultSdlParserOptions() + + void setup() { + ParserOptions.setDefaultParserOptions(defaultOptions) + ParserOptions.setDefaultOperationParserOptions(defaultOperationOptions) + ParserOptions.setDefaultSdlParserOptions(defaultSdlOptions) + } + + void cleanup() { + ParserOptions.setDefaultParserOptions(defaultOptions) + ParserOptions.setDefaultOperationParserOptions(defaultOperationOptions) + ParserOptions.setDefaultSdlParserOptions(defaultSdlOptions) + } + + + def "a billion laughs attack will be prevented by default"() { + def lol = "@lol" * 10000 // two tokens = 20000+ tokens + def text = "query { f $lol }" + when: + Parser.parse(text) + + then: + def e = thrown(ParseCancelledException) + e.getMessage().contains("parsing has been cancelled") + + when: "integration test to prove it cancels by default" + + def sdl = """type Query { f : ID} """ + def graphQL = TestUtil.graphQL(sdl).build() + def er = graphQL.execute(text) + then: + er.errors.size() == 1 + er.errors[0].message.contains("parsing has been cancelled") + } + + def "a large whitespace laughs attack will be prevented by default"() { + def spaces = " " * 300_000 + def text = "query { f $spaces }" + when: + Parser.parse(text) + + then: + def e = thrown(ParseCancelledException) + e.getMessage().contains("parsing has been cancelled") + + when: "integration test to prove it cancels by default" + + def sdl = """type Query { f : ID} """ + def graphQL = TestUtil.graphQL(sdl).build() + def er = graphQL.execute(text) + then: + er.errors.size() == 1 + er.errors[0].message.contains("parsing has been cancelled") + } + + def "they can shoot themselves if they want to with large documents"() { + def lol = "@lol" * 10000 // two tokens = 20000+ tokens + def text = "query { f $lol }" + + def options = ParserOptions.newParserOptions().maxTokens(30000).build() + + when: + def doc = new Parser().parseDocument(text, options) + + then: + doc != null + } + + def "they can shoot themselves if they want to with large documents with lots of whitespace"() { + def spaces = " " * 300_000 + def text = "query { f $spaces }" + + def options = ParserOptions.newParserOptions().maxWhitespaceTokens(Integer.MAX_VALUE).build() + when: + def doc = new Parser().parseDocument(text, options) + + then: + doc != null + } + + def "they can set their own listener into action"() { + def queryText = "query { f(arg : 1) }" + + def count = 0 + def tokens = [] + ParsingListener listener = { count++; tokens.add(it.getText()) } + def parserOptions = ParserOptions.newParserOptions().parsingListener(listener).build() + + when: + def doc = new Parser().parseDocument(queryText, parserOptions) + + then: + doc != null + count == 9 + tokens == ["query", "{", "f", "(", "arg", ":", "1", ")", "}"] + + when: "integration test to prove it be supplied via EI" + + def sdl = """type Query { f(arg : Int) : ID} """ + def graphQL = TestUtil.graphQL(sdl).build() + + + def context = [:] + context.put(ParserOptions.class, parserOptions) + def executionInput = ExecutionInput.newExecutionInput() + .query(queryText) + .graphQLContext(context).build() + + count = 0 + tokens = [] + def er = graphQL.execute(executionInput) + then: + er.errors.size() == 0 + count == 9 + tokens == ["query", "{", "f", "(", "arg", ":", "1", ")", "}"] + + } + + def "deep query stack overflows are prevented by limiting the depth of rules"() { + String text = mkDeepQuery(10000) + + when: + new Parser().parseDocument(text, defaultOperationOptions) + + then: + thrown(ParseCancelledTooDeepException) + } + + def "wide queries are prevented by max token counts"() { + String text = mkWideQuery(10000) + + when: + + new Parser().parseDocument(text, defaultOperationOptions) + + then: + thrown(ParseCancelledException) // too many tokens will catch this wide queries + } + + String mkDeepQuery(int howMany) { + def field = 'f(a:"")' + StringBuilder sb = new StringBuilder("query q{") + for (int i = 0; i < howMany; i++) { + sb.append(field) + if (i < howMany - 1) { + sb.append("{") + } + } + for (int i = 0; i < howMany - 1; i++) { + sb.append("}") + } + sb.append("}") + return sb.toString() + } + + String mkWideQuery(int howMany) { + StringBuilder sb = new StringBuilder("query q{f(") + for (int i = 0; i < howMany; i++) { + sb.append('a:1,') + } + sb.append(")}") + return sb.toString() + } +} diff --git a/src/test/groovy/graphql/parser/ParserTest.groovy b/src/test/groovy/graphql/parser/ParserTest.groovy index 6a76ace1b4..c80bf8a988 100644 --- a/src/test/groovy/graphql/parser/ParserTest.groovy +++ b/src/test/groovy/graphql/parser/ParserTest.groovy @@ -1147,104 +1147,5 @@ triple3 : """edge cases \\""" "" " \\"" \\" edge cases""" document.getDefinitions()[0].getSourceLocation() == SourceLocation.EMPTY } - def "a billion laughs attack will be prevented by default"() { - def lol = "@lol" * 10000 // two tokens = 20000+ tokens - def text = "query { f $lol }" - when: - Parser.parse(text) - - then: - def e = thrown(ParseCancelledException) - e.getMessage().contains("parsing has been cancelled") - - when: "integration test to prove it cancels by default" - - def sdl = """type Query { f : ID} """ - def graphQL = TestUtil.graphQL(sdl).build() - def er = graphQL.execute(text) - then: - er.errors.size() == 1 - er.errors[0].message.contains("parsing has been cancelled") - } - - def "a large whitespace laughs attack will be prevented by default"() { - def spaces = " " * 300_000 - def text = "query { f $spaces }" - when: - Parser.parse(text) - - then: - def e = thrown(ParseCancelledException) - e.getMessage().contains("parsing has been cancelled") - - when: "integration test to prove it cancels by default" - - def sdl = """type Query { f : ID} """ - def graphQL = TestUtil.graphQL(sdl).build() - def er = graphQL.execute(text) - then: - er.errors.size() == 1 - er.errors[0].message.contains("parsing has been cancelled") - } - def "they can shoot themselves if they want to with large documents"() { - def lol = "@lol" * 10000 // two tokens = 20000+ tokens - def text = "query { f $lol }" - - def options = ParserOptions.newParserOptions().maxTokens(30000).build() - when: - def doc = new Parser().parseDocument(text, options) - - then: - doc != null - } - - def "they can shoot themselves if they want to with large documents with lots of whitespace"() { - def spaces = " " * 300_000 - def text = "query { f $spaces }" - - def options = ParserOptions.newParserOptions().maxWhitespaceTokens(Integer.MAX_VALUE).build() - when: - def doc = new Parser().parseDocument(text, options) - - then: - doc != null - } - - def "they can set their own listener into action"() { - def queryText = "query { f(arg : 1) }" - - def count = 0 - def tokens = [] - ParsingListener listener = { count++; tokens.add(it.getText()) } - def parserOptions = ParserOptions.newParserOptions().parsingListener(listener).build() - when: - def doc = new Parser().parseDocument(queryText, parserOptions) - - then: - doc != null - count == 9 - tokens == ["query", "{", "f", "(", "arg", ":", "1", ")", "}"] - - when: "integration test to prove it be supplied via EI" - - def sdl = """type Query { f(arg : Int) : ID} """ - def graphQL = TestUtil.graphQL(sdl).build() - - - def context = [:] - context.put(ParserOptions.class, parserOptions) - def executionInput = ExecutionInput.newExecutionInput() - .query(queryText) - .graphQLContext(context).build() - - count = 0 - tokens = [] - def er = graphQL.execute(executionInput) - then: - er.errors.size() == 0 - count == 9 - tokens == ["query", "{", "f", "(", "arg", ":", "1", ")", "}"] - - } }