Skip to content

Commit ebe09fe

Browse files
Harden stack overflow protection (#966)
* Add failing tests for stack overflow * Add overflow protection to variable validation * You need a lot to blow the stack here * Lint * Add overflow protection in subscription validation * Respect recursion limit in Fragment Is Used validation Also speed it up significantly by only walking the entire AST once, instead of doing it again for every fragment definition. * lint * comment tweaks * Update snapshots
1 parent df16557 commit ebe09fe

File tree

6 files changed

+266
-50
lines changed

6 files changed

+266
-50
lines changed

crates/apollo-compiler/src/executable/validation.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use super::FieldSet;
2-
use crate::validation::fragment::validate_fragment_used;
2+
use crate::validation::fragment::validate_fragments_used;
33
use crate::validation::operation::validate_operation_definitions;
44
use crate::validation::selection::FieldsInSetCanMerge;
55
use crate::validation::DiagnosticList;
@@ -44,9 +44,7 @@ pub(crate) fn validate_with_or_without_schema(
4444
) {
4545
let context = ExecutableValidationContext::new(schema);
4646
validate_operation_definitions(errors, document, &context);
47-
for def in document.fragments.values() {
48-
validate_fragment_used(errors, document, def);
49-
}
47+
validate_fragments_used(errors, document);
5048
}
5149

5250
pub(crate) fn validate_field_set(

crates/apollo-compiler/src/validation/fragment.rs

Lines changed: 32 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ use crate::executable;
77
use crate::schema;
88
use crate::schema::Implementers;
99
use crate::validation::diagnostics::DiagnosticData;
10+
use crate::validation::variable::walk_selections_with_deduped_fragments;
1011
use crate::validation::CycleError;
1112
use crate::validation::DiagnosticList;
1213
use crate::validation::OperationValidationContext;
1314
use crate::validation::RecursionGuard;
15+
use crate::validation::RecursionLimitError;
1416
use crate::validation::RecursionStack;
1517
use crate::validation::SourceSpan;
1618
use crate::ExecutableDocument;
@@ -376,49 +378,40 @@ pub(crate) fn validate_fragment_type_condition(
376378
}
377379
}
378380

379-
pub(crate) fn validate_fragment_used(
380-
diagnostics: &mut DiagnosticList,
381+
fn collect_used_fragments(
381382
document: &ExecutableDocument,
382-
fragment: &Node<executable::Fragment>,
383-
) {
384-
let fragment_name = &fragment.name;
385-
386-
let mut all_selections = document
387-
.operations
388-
.iter()
389-
.map(|operation| &operation.selection_set)
390-
.chain(
391-
document
392-
.fragments
393-
.values()
394-
.map(|fragment| &fragment.selection_set),
395-
)
396-
.flat_map(|set| &set.selections);
397-
398-
let is_used = all_selections.any(|sel| selection_uses_fragment(sel, fragment_name));
399-
400-
// Fragments must be used within the schema
401-
//
402-
// Returns Unused Fragment error.
403-
if !is_used {
404-
diagnostics.push(
405-
fragment.location(),
406-
DiagnosticData::UnusedFragment {
407-
name: fragment_name.clone(),
408-
},
409-
)
383+
) -> Result<HashSet<&Name>, RecursionLimitError> {
384+
let mut names = HashSet::default();
385+
for operation in document.operations.iter() {
386+
walk_selections_with_deduped_fragments(document, &operation.selection_set, |selection| {
387+
if let executable::Selection::FragmentSpread(spread) = selection {
388+
names.insert(&spread.fragment_name);
389+
}
390+
})?;
410391
}
392+
Ok(names)
411393
}
412394

413-
fn selection_uses_fragment(sel: &executable::Selection, name: &str) -> bool {
414-
let sub_selections = match sel {
415-
executable::Selection::FragmentSpread(fragment) => return fragment.fragment_name == name,
416-
executable::Selection::Field(field) => &field.selection_set,
417-
executable::Selection::InlineFragment(inline) => &inline.selection_set,
395+
pub(crate) fn validate_fragments_used(
396+
diagnostics: &mut DiagnosticList,
397+
document: &ExecutableDocument,
398+
) {
399+
let Ok(used_fragments) = collect_used_fragments(document) else {
400+
diagnostics.push(None, super::Details::RecursionLimitError);
401+
return;
418402
};
419403

420-
sub_selections
421-
.selections
422-
.iter()
423-
.any(|sel| selection_uses_fragment(sel, name))
404+
for fragment in document.fragments.values() {
405+
// Fragments must be used within the schema
406+
//
407+
// Returns Unused Fragment error.
408+
if !used_fragments.contains(&fragment.name) {
409+
diagnostics.push(
410+
fragment.location(),
411+
DiagnosticData::UnusedFragment {
412+
name: fragment.name.clone(),
413+
},
414+
)
415+
}
416+
}
424417
}

crates/apollo-compiler/src/validation/mod.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,6 +1089,60 @@ const DEFAULT_RECURSION_LIMIT: usize = 32;
10891089
#[non_exhaustive]
10901090
struct RecursionLimitError {}
10911091

1092+
/// Track recursion depth to prevent stack overflow.
1093+
#[derive(Debug)]
1094+
struct DepthCounter {
1095+
value: usize,
1096+
high: usize,
1097+
limit: usize,
1098+
}
1099+
1100+
impl DepthCounter {
1101+
fn new() -> Self {
1102+
Self {
1103+
value: 0,
1104+
high: 0,
1105+
limit: DEFAULT_RECURSION_LIMIT,
1106+
}
1107+
}
1108+
1109+
fn with_limit(mut self, limit: usize) -> Self {
1110+
self.limit = limit;
1111+
self
1112+
}
1113+
1114+
/// Return the actual API for tracking recursive uses.
1115+
pub(crate) fn guard(&mut self) -> DepthGuard<'_> {
1116+
DepthGuard(self)
1117+
}
1118+
}
1119+
1120+
/// Track call depth in a recursive function.
1121+
///
1122+
/// Pass the result of `guard.increment()` to recursive calls. When a guard is dropped,
1123+
/// its value is decremented.
1124+
struct DepthGuard<'a>(&'a mut DepthCounter);
1125+
1126+
impl DepthGuard<'_> {
1127+
/// Mark that we are recursing. If we reached the limit, return an error.
1128+
fn increment(&mut self) -> Result<DepthGuard<'_>, RecursionLimitError> {
1129+
self.0.value += 1;
1130+
self.0.high = self.0.high.max(self.0.value);
1131+
if self.0.value > self.0.limit {
1132+
Err(RecursionLimitError {})
1133+
} else {
1134+
Ok(DepthGuard(self.0))
1135+
}
1136+
}
1137+
}
1138+
1139+
impl Drop for DepthGuard<'_> {
1140+
fn drop(&mut self) {
1141+
// This may already be 0 if it's the original `counter.guard()` result, but that's fine
1142+
self.0.value = self.0.value.saturating_sub(1);
1143+
}
1144+
}
1145+
10921146
/// Track used names in a recursive function.
10931147
#[derive(Debug)]
10941148
struct RecursionStack {

crates/apollo-compiler/src/validation/operation.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use crate::collections::HashSet;
22
use crate::executable;
33
use crate::validation::diagnostics::DiagnosticData;
4+
use crate::validation::DepthCounter;
5+
use crate::validation::DepthGuard;
46
use crate::validation::DiagnosticList;
57
use crate::validation::ExecutableValidationContext;
68
use crate::validation::RecursionLimitError;
@@ -23,6 +25,7 @@ fn walk_selections<'doc>(
2325
document: &'doc ExecutableDocument,
2426
selection_set: &'doc executable::SelectionSet,
2527
seen: &mut HashSet<&'doc Name>,
28+
mut guard: DepthGuard<'_>,
2629
f: &mut dyn FnMut(&'doc executable::Selection),
2730
) -> Result<(), RecursionLimitError> {
2831
for selection in &selection_set.selections {
@@ -45,19 +48,38 @@ fn walk_selections<'doc>(
4548
document,
4649
&fragment_definition.selection_set,
4750
seen,
51+
guard.increment()?,
4852
f,
4953
)?;
5054
}
5155
}
5256
executable::Selection::InlineFragment(fragment) => {
53-
walk_selections_inner(document, &fragment.selection_set, seen, f)?;
57+
walk_selections_inner(
58+
document,
59+
&fragment.selection_set,
60+
seen,
61+
guard.increment()?,
62+
f,
63+
)?;
5464
}
5565
}
5666
}
5767
Ok(())
5868
}
5969

60-
walk_selections_inner(document, selections, &mut HashSet::default(), &mut f)
70+
// This has a much higher limit than comparable recursive walks, like the one in
71+
// `validate_fragment_cycles`, despite doing similar work. This is because this limit
72+
// was introduced later and should not break (reasonable) existing queries that are
73+
// under that pre-existing limit. Luckily the existing limit was very conservative.
74+
let mut depth = DepthCounter::new().with_limit(500);
75+
76+
walk_selections_inner(
77+
document,
78+
selections,
79+
&mut HashSet::default(),
80+
depth.guard(),
81+
&mut f,
82+
)
6183
}
6284

6385
pub(crate) fn validate_subscription(

crates/apollo-compiler/src/validation/variable.rs

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use crate::collections::HashSet;
44
use crate::executable;
55
use crate::validation::diagnostics::DiagnosticData;
66
use crate::validation::value::value_of_correct_type;
7+
use crate::validation::DepthCounter;
8+
use crate::validation::DepthGuard;
79
use crate::validation::DiagnosticList;
810
use crate::validation::RecursionLimitError;
911
use crate::validation::SourceSpan;
@@ -88,7 +90,7 @@ pub(crate) fn validate_variable_definitions(
8890
///
8991
/// Named fragments are "deduplicated": only visited once even if spread multiple times *in
9092
/// different locations*. This is only appropriate for certain kinds of validations, so reuser beware.
91-
fn walk_selections_with_deduped_fragments<'doc>(
93+
pub(super) fn walk_selections_with_deduped_fragments<'doc>(
9294
document: &'doc ExecutableDocument,
9395
selections: &'doc executable::SelectionSet,
9496
mut f: impl FnMut(&'doc executable::Selection),
@@ -97,13 +99,20 @@ fn walk_selections_with_deduped_fragments<'doc>(
9799
document: &'doc ExecutableDocument,
98100
selection_set: &'doc executable::SelectionSet,
99101
seen: &mut HashSet<&'doc Name>,
102+
mut guard: DepthGuard<'_>,
100103
f: &mut dyn FnMut(&'doc executable::Selection),
101104
) -> Result<(), RecursionLimitError> {
102105
for selection in &selection_set.selections {
103106
f(selection);
104107
match selection {
105108
executable::Selection::Field(field) => {
106-
walk_selections_inner(document, &field.selection_set, seen, f)?;
109+
walk_selections_inner(
110+
document,
111+
&field.selection_set,
112+
seen,
113+
guard.increment()?,
114+
f,
115+
)?;
107116
}
108117
executable::Selection::FragmentSpread(fragment) => {
109118
let new = seen.insert(&fragment.fragment_name);
@@ -118,19 +127,37 @@ fn walk_selections_with_deduped_fragments<'doc>(
118127
document,
119128
&fragment_definition.selection_set,
120129
seen,
130+
guard.increment()?,
121131
f,
122132
)?;
123133
}
124134
}
125135
executable::Selection::InlineFragment(fragment) => {
126-
walk_selections_inner(document, &fragment.selection_set, seen, f)?;
136+
walk_selections_inner(
137+
document,
138+
&fragment.selection_set,
139+
seen,
140+
guard.increment()?,
141+
f,
142+
)?;
127143
}
128144
}
129145
}
130146
Ok(())
131147
}
132148

133-
walk_selections_inner(document, selections, &mut HashSet::default(), &mut f)
149+
// This has a much higher limit than comparable recursive walks, like the one in
150+
// `validate_fragment_cycles`, despite doing similar work. This is because this limit
151+
// was introduced later and should not break (reasonable) existing queries that are
152+
// under that pre-existing limit. Luckily the existing limit was very conservative.
153+
let mut depth = DepthCounter::new().with_limit(500);
154+
walk_selections_inner(
155+
document,
156+
selections,
157+
&mut HashSet::default(),
158+
depth.guard(),
159+
&mut f,
160+
)
134161
}
135162

136163
fn variables_in_value(value: &ast::Value) -> impl Iterator<Item = &Name> + '_ {

0 commit comments

Comments
 (0)