Skip to content

Commit 7f27a04

Browse files
bbakermanjord1e
andauthored
Help prevent DOS attacks on graphql servers (#2549)
* This adds a maximum number of tokens to be parse in queries by default * Fixed test * Update src/main/java/graphql/parser/Parser.java Co-authored-by: Jordie <30464310+jord1e@users.noreply.github.com> Co-authored-by: Jordie <30464310+jord1e@users.noreply.github.com>
1 parent 23d352f commit 7f27a04

File tree

7 files changed

+186
-7
lines changed

7 files changed

+186
-7
lines changed

src/main/java/graphql/parser/GraphqlAntlrToLanguage.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ public GraphqlAntlrToLanguage(CommonTokenStream tokens, MultiSourceReader multiS
9999
this.parserOptions = parserOptions == null ? ParserOptions.getDefaultParserOptions() : parserOptions;
100100
}
101101

102+
public ParserOptions getParserOptions() {
103+
return parserOptions;
104+
}
105+
102106
//MARKER START: Here GraphqlOperation.g4 specific methods begin
103107

104108

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package graphql.parser;
2+
3+
import graphql.PublicApi;
4+
import graphql.language.SourceLocation;
5+
6+
@PublicApi
7+
public class ParseCancelledException extends InvalidSyntaxException {
8+
9+
public ParseCancelledException(String msg, SourceLocation sourceLocation, String offendingToken) {
10+
super(sourceLocation, msg, null, offendingToken, null);
11+
}
12+
}

src/main/java/graphql/parser/Parser.java

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import graphql.language.Node;
66
import graphql.language.SourceLocation;
77
import graphql.language.Value;
8+
import graphql.parser.antlr.GraphqlBaseListener;
89
import graphql.parser.antlr.GraphqlLexer;
910
import graphql.parser.antlr.GraphqlParser;
1011
import org.antlr.v4.runtime.BaseErrorListener;
@@ -16,6 +17,8 @@
1617
import org.antlr.v4.runtime.Recognizer;
1718
import org.antlr.v4.runtime.Token;
1819
import org.antlr.v4.runtime.atn.PredictionMode;
20+
import org.antlr.v4.runtime.tree.ParseTreeListener;
21+
import org.antlr.v4.runtime.tree.TerminalNode;
1922

2023
import java.io.IOException;
2124
import java.io.Reader;
@@ -144,7 +147,7 @@ public Document parseDocument(Reader reader, ParserOptions parserOptions) throws
144147
return parseDocumentImpl(reader, parserOptions);
145148
}
146149

147-
private Document parseDocumentImpl(Reader reader, ParserOptions parserOptions) throws InvalidSyntaxException {
150+
private Document parseDocumentImpl(Reader reader, ParserOptions parserOptions) throws InvalidSyntaxException, ParseCancelledException {
148151
BiFunction<GraphqlParser, GraphqlAntlrToLanguage, Object[]> nodeFunction = (parser, toLanguage) -> {
149152
GraphqlParser.DocumentContext documentContext = parser.document();
150153
Document doc = toLanguage.createDocument(documentContext);
@@ -188,7 +191,7 @@ private Node<?> parseImpl(Reader reader, BiFunction<GraphqlParser, GraphqlAntlrT
188191
public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol, int line, int charPositionInLine, String msg, RecognitionException e) {
189192
SourceLocation sourceLocation = AntlrHelper.createSourceLocation(multiSourceReader, line, charPositionInLine);
190193
String preview = AntlrHelper.createPreview(multiSourceReader, line);
191-
throw new InvalidSyntaxException(sourceLocation, "Invalid syntax: " + msg, preview, null, null);
194+
throw new InvalidSyntaxException(sourceLocation, msg, preview, null, null);
192195
}
193196
});
194197

@@ -206,6 +209,13 @@ public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol, int
206209
if (toLanguage == null) {
207210
toLanguage = getAntlrToLanguage(tokens, multiSourceReader, parserOptions);
208211
}
212+
213+
setupParserListener(multiSourceReader, parser, toLanguage);
214+
215+
216+
//
217+
// parsing starts ...... now!
218+
//
209219
Object[] contextAndNode = nodeFunction.apply(parser, toLanguage);
210220
ParserRuleContext parserRuleContext = (ParserRuleContext) contextAndNode[0];
211221
Node<?> node = (Node<?>) contextAndNode[1];
@@ -227,6 +237,31 @@ public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol, int
227237
return node;
228238
}
229239

240+
private void setupParserListener(MultiSourceReader multiSourceReader, GraphqlParser parser, GraphqlAntlrToLanguage toLanguage) {
241+
int maxTokens = toLanguage.getParserOptions().getMaxTokens();
242+
// prevent a billion laugh attacks by restricting how many tokens we allow
243+
ParseTreeListener listener = new GraphqlBaseListener() {
244+
int count = 0;
245+
246+
@Override
247+
public void visitTerminal(TerminalNode node) {
248+
count++;
249+
if (count > maxTokens) {
250+
String msg = String.format("More than %d parse tokens have been presented. To prevent Denial Of Service attacks, parsing has been cancelled.", maxTokens);
251+
SourceLocation sourceLocation = null;
252+
String offendingToken = null;
253+
if (node.getSymbol() != null) {
254+
offendingToken = node.getText();
255+
sourceLocation = AntlrHelper.createSourceLocation(multiSourceReader, node.getSymbol().getLine(), node.getSymbol().getCharPositionInLine());
256+
}
257+
258+
throw new ParseCancelledException(msg, sourceLocation, offendingToken);
259+
}
260+
}
261+
};
262+
parser.addParseListener(listener);
263+
}
264+
230265
/**
231266
* Allows you to override the ANTLR to AST code.
232267
*

src/main/java/graphql/parser/ParserOptions.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,30 @@
33
import graphql.Assert;
44
import graphql.PublicApi;
55

6+
import java.util.function.Consumer;
7+
68
/**
79
* Options that control how the {@link Parser} behaves.
810
*/
911
@PublicApi
1012
public class ParserOptions {
1113

14+
/**
15+
* An graphql hacking vector is to send nonsensical queries that burn lots of parsing CPU time and burn
16+
* memory representing a document that wont ever execute. To prevent this for most users, graphql-java
17+
* set this value to 15000. ANTLR parsing time is linear to the number of tokens presented. The more you
18+
* allow the longer it takes.
19+
*
20+
* If you want to allow more, then {@link #setDefaultParserOptions(ParserOptions)} allows you to change this
21+
* JVM wide.
22+
*/
23+
public static int MAX_QUERY_TOKENS = 15000;
24+
1225
private static ParserOptions defaultJvmParserOptions = newParserOptions()
1326
.captureIgnoredChars(false)
1427
.captureSourceLocation(true)
28+
.maxTokens(MAX_QUERY_TOKENS) // to prevent a billion laughs style attacks, we set a default for graphql-java
29+
1530
.build();
1631

1732
/**
@@ -50,10 +65,12 @@ public static void setDefaultParserOptions(ParserOptions options) {
5065

5166
private final boolean captureIgnoredChars;
5267
private final boolean captureSourceLocation;
68+
private final int maxTokens;
5369

5470
private ParserOptions(Builder builder) {
5571
this.captureIgnoredChars = builder.captureIgnoredChars;
5672
this.captureSourceLocation = builder.captureSourceLocation;
73+
this.maxTokens = builder.maxTokens;
5774
}
5875

5976
/**
@@ -79,6 +96,23 @@ public boolean isCaptureSourceLocation() {
7996
return captureSourceLocation;
8097
}
8198

99+
/**
100+
* An graphql hacking vector is to send nonsensical queries that burn lots of parsing CPU time and burn
101+
* memory representing a document that wont ever execute. To prevent this you can set a maximum number of parse
102+
* tokens that will be accepted before an exception is thrown and the parsing is stopped.
103+
*
104+
* @return the maximum number of raw tokens the parser will accept, after which an exception will be thrown.
105+
*/
106+
public int getMaxTokens() {
107+
return maxTokens;
108+
}
109+
110+
public ParserOptions transform(Consumer<Builder> builderConsumer) {
111+
Builder builder = new Builder(this);
112+
builderConsumer.accept(builder);
113+
return builder.build();
114+
}
115+
82116
public static Builder newParserOptions() {
83117
return new Builder();
84118
}
@@ -87,6 +121,16 @@ public static class Builder {
87121

88122
private boolean captureIgnoredChars = false;
89123
private boolean captureSourceLocation = true;
124+
private int maxTokens = MAX_QUERY_TOKENS;
125+
126+
Builder() {
127+
}
128+
129+
Builder(ParserOptions parserOptions) {
130+
this.captureIgnoredChars = parserOptions.captureIgnoredChars;
131+
this.captureSourceLocation = parserOptions.captureSourceLocation;
132+
this.maxTokens = parserOptions.maxTokens;
133+
}
90134

91135
public Builder captureIgnoredChars(boolean captureIgnoredChars) {
92136
this.captureIgnoredChars = captureIgnoredChars;
@@ -98,6 +142,11 @@ public Builder captureSourceLocation(boolean captureSourceLocation) {
98142
return this;
99143
}
100144

145+
public Builder maxTokens(int maxTokens) {
146+
this.maxTokens = maxTokens;
147+
return this;
148+
}
149+
101150
public ParserOptions build() {
102151
return new ParserOptions(this);
103152
}

src/main/java/graphql/schema/idl/SchemaParser.java

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@
88
import graphql.language.SDLDefinition;
99
import graphql.parser.InvalidSyntaxException;
1010
import graphql.parser.Parser;
11+
import graphql.parser.ParserOptions;
1112
import graphql.schema.idl.errors.NonSDLDefinitionError;
1213
import graphql.schema.idl.errors.SchemaProblem;
1314

14-
import java.io.InputStream;
15-
import java.io.InputStreamReader;
1615
import java.io.File;
1716
import java.io.IOException;
17+
import java.io.InputStream;
18+
import java.io.InputStreamReader;
1819
import java.io.Reader;
1920
import java.io.StringReader;
2021
import java.nio.file.Files;
@@ -71,8 +72,22 @@ public TypeDefinitionRegistry parse(InputStream inputStream) throws SchemaProble
7172
* @throws SchemaProblem if there are problems compiling the schema definitions
7273
*/
7374
public TypeDefinitionRegistry parse(Reader reader) throws SchemaProblem {
75+
return parse(reader, null);
76+
}
77+
78+
/**
79+
* Parse a reader of schema definitions and create a {@link TypeDefinitionRegistry}
80+
*
81+
* @param reader the reader to parse
82+
* @param parserOptions the parse options to use while parsing
83+
*
84+
* @return registry of type definitions
85+
*
86+
* @throws SchemaProblem if there are problems compiling the schema definitions
87+
*/
88+
public TypeDefinitionRegistry parse(Reader reader, ParserOptions parserOptions) throws SchemaProblem {
7489
try (Reader input = reader) {
75-
return parseImpl(input);
90+
return parseImpl(input, parserOptions);
7691
} catch (IOException e) {
7792
throw new RuntimeException(e);
7893
}
@@ -92,9 +107,19 @@ public TypeDefinitionRegistry parse(String schemaInput) throws SchemaProblem {
92107
}
93108

94109
public TypeDefinitionRegistry parseImpl(Reader schemaInput) {
110+
// why it this public - (head shake)
111+
return parseImpl(schemaInput, null);
112+
}
113+
114+
private TypeDefinitionRegistry parseImpl(Reader schemaInput, ParserOptions parseOptions) {
95115
try {
116+
if (parseOptions == null) {
117+
// for SDL we dont stop how many parser tokens there are - its not the attack vector
118+
// to be prevented compared to queries
119+
parseOptions = ParserOptions.getDefaultParserOptions().transform(opts -> opts.maxTokens(Integer.MAX_VALUE));
120+
}
96121
Parser parser = new Parser();
97-
Document document = parser.parseDocument(schemaInput);
122+
Document document = parser.parseDocument(schemaInput, parseOptions);
98123

99124
return buildRegistry(document);
100125
} catch (InvalidSyntaxException e) {

src/test/groovy/graphql/parser/ParserTest.groovy

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package graphql.parser
22

33

4+
import graphql.TestUtil
45
import graphql.language.Argument
56
import graphql.language.ArrayValue
67
import graphql.language.AstComparator
@@ -796,7 +797,7 @@ triple3 : """edge cases \\""" "" " \\"" \\" edge cases"""
796797
println document
797798
then:
798799
def e = thrown(InvalidSyntaxException)
799-
e.message.contains("Invalid syntax")
800+
e.message.contains("Invalid Syntax")
800801
e.sourcePreview == input + "\n"
801802
e.location.line == 3
802803
e.location.column == 20
@@ -1071,4 +1072,36 @@ triple3 : """edge cases \\""" "" " \\"" \\" edge cases"""
10711072
document.getSourceLocation() == SourceLocation.EMPTY
10721073
document.getDefinitions()[0].getSourceLocation() == SourceLocation.EMPTY
10731074
}
1075+
1076+
def "a billion laughs attack will be prevented by default"() {
1077+
def lol = "@lol" * 10000 // two tokens = 20000+ tokens
1078+
def text = "query { f $lol }"
1079+
when:
1080+
Parser.parse(text)
1081+
1082+
then:
1083+
def e = thrown(ParseCancelledException)
1084+
e.getMessage().contains("parsing has been cancelled")
1085+
1086+
when: "integration test to prove it cancels by default"
1087+
1088+
def sdl = """type Query { f : ID} """
1089+
def graphQL = TestUtil.graphQL(sdl).build()
1090+
def er = graphQL.execute(text)
1091+
then:
1092+
er.errors.size() == 1
1093+
er.errors[0].message.contains("parsing has been cancelled")
1094+
}
1095+
1096+
def "they can shoot themselves if they want to with large documents"() {
1097+
def lol = "@lol" * 10000 // two tokens = 20000+ tokens
1098+
def text = "query { f $lol }"
1099+
1100+
def options = ParserOptions.newParserOptions().maxTokens(30000).build()
1101+
when:
1102+
def doc = new Parser().parseDocument(text, options)
1103+
1104+
then:
1105+
doc != null
1106+
}
10741107
}

src/test/groovy/graphql/schema/idl/SchemaParserTest.groovy

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import graphql.language.EnumTypeDefinition
44
import graphql.language.InterfaceTypeDefinition
55
import graphql.language.ObjectTypeDefinition
66
import graphql.language.ScalarTypeDefinition
7+
import graphql.parser.ParserOptions
78
import graphql.schema.idl.errors.SchemaProblem
89
import spock.lang.Specification
910
import spock.lang.Unroll
@@ -338,5 +339,25 @@ class SchemaParserTest extends Specification {
338339
schemaProblem.getErrors()[2].getMessage().contains("OperationDefinition")
339340
}
340341
342+
def "large schema files can be parsed - there is no limit"() {
343+
def sdl = "type Query {\n"
344+
for (int i = 0; i < 30000; i++) {
345+
sdl += " f" + i + " : ID\n"
346+
}
347+
sdl += "}"
348+
349+
when:
350+
def typeDefinitionRegistry = new SchemaParser().parse(sdl)
351+
then:
352+
typeDefinitionRegistry != null
341353
354+
355+
when: "options are used they will be respected"
356+
def options = ParserOptions.defaultParserOptions.transform({ it.maxTokens(100) })
357+
new SchemaParser().parse(new StringReader(sdl), options)
358+
then:
359+
def e = thrown(SchemaProblem)
360+
e.errors[0].message.contains("parsing has been cancelled")
361+
362+
}
342363
}

0 commit comments

Comments
 (0)