-
Notifications
You must be signed in to change notification settings - Fork 15
#576 Escape single quotes in query interpolation #615
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
50f1ec0
7ec907d
9cec45b
6ca35f8
2c2529e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,11 +35,11 @@ def Fbe.regularly(area, p_every_days, p_since_days = nil, fb: Fbe.fb, judge: $ju | |
| raise(Fbe::Error, 'The fb is nil') if fb.nil? | ||
| raise(Fbe::Error, 'The $judge is not set') if judge.nil? | ||
| raise(Fbe::Error, 'The $loog is not set') if loog.nil? | ||
| pmp = fb.query("(and (eq what 'pmp') (eq area '#{area}') (exists #{p_every_days}))").each.first | ||
| pmp = fb.query("(and (eq what 'pmp') (eq area '#{area.gsub("'", "\\\\'")}') (exists #{p_every_days}))").each.first | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The fix here is validation, not escaping. factbase accepts only |
||
| interval = pmp.nil? ? 7 : pmp[p_every_days].first | ||
| recent = fb.query( | ||
| "(and | ||
| (eq what '#{judge}') | ||
| (eq what '#{judge.gsub("'", "\\\\'")}') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because the chosen scheme is backslash-based, So a value ending in a single backslash eats the closing quote. Both gaps (this one and the |
||
| (gt when (minus (to_time (env 'TODAY' '#{Time.now.utc.iso8601}')) '#{interval} days')))" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Issue #576 asked for this explicitly ("The interval value from PMP is also interpolated as-is without numeric validation"). |
||
| ).each.first | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Before refining the escape: the query API already supports binding, so string substitution is the weaker of two options that are both available today. Written that way the two queries here become One genuine blocker to check before switching, so this is not a naive "just use params" suggestion: |
||
| if recent | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,23 +37,23 @@ def Fbe.repeatedly(area, p_every_hours, fb: Fbe.fb, judge: $judge, loog: $loog, | |
| raise(Fbe::Error, 'The fb is nil') if fb.nil? | ||
| raise(Fbe::Error, 'The $judge is not set') if judge.nil? | ||
| raise(Fbe::Error, 'The $loog is not set') if loog.nil? | ||
| pmp = fb.query("(and (eq what 'pmp') (eq area '#{area}') (exists #{p_every_hours}))").each.first | ||
| pmp = fb.query("(and (eq what 'pmp') (eq area '#{area.gsub("'", "\\\\'")}') (exists #{p_every_hours}))").each.first | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This line is fixed but its twin is not. query = ->(area) { Fbe.fb(global:, fb:, options:, loog:).query("(and (eq what 'pmp') (eq area '#{area}'))") }and there This is the argument for extracting one helper rather than inlining |
||
| hours = pmp.nil? ? 24 : pmp[p_every_hours].first | ||
| recent = fb.query( | ||
| "(and | ||
| (eq what '#{judge}') | ||
| (eq what '#{judge.gsub("'", "\\\\'")}') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For prioritisation it is also worth recording where the real exposure is. |
||
| (gt when (minus (to_time (env 'TODAY' '#{Time.now.utc.iso8601}')) '#{hours} hours')))" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same defect as Verified against the 0.19.11 tokenizer: with
|
||
| ).each.first | ||
| if recent | ||
| loog.info("#{judge} was executed #{recent.when.ago} ago, skipping now (we run it every #{hours} hours)") | ||
| return | ||
| end | ||
| f = fb.query("(and (eq what '#{judge}'))").each.first | ||
| f = fb.query("(and (eq what '#{judge.gsub("'", "\\\\'")}'))").each.first | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The block form has no replacement grammar at all, is readable, and takes care of the unescaped-backslash gap flagged on This is also the sixth copy of the same expression added by this PR, on top of the two that already exist at |
||
| if f.nil? | ||
| f = fb.insert | ||
| f.what = judge | ||
| end | ||
| yield(fb.query("(and (eq what '#{judge}'))").each.first) | ||
| yield(fb.query("(and (eq what '#{judge.gsub("'", "\\\\'")}'))").each.first) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This rebuilds the exact string already built on line 51, so the escape now runs twice and any future change to it has to be made in two places. Hoist it: The more substantive point is the asymmetry this PR introduces on the write/read pair. Line 54 stores the raw When the round-trip fails, |
||
| Fbe.overwrite(f, 'when', Time.now) | ||
| nil | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -69,4 +69,34 @@ def test_uses_default_since_days_when_pmp_lacks_property | |
| refute_nil(fact) | ||
| refute_nil(fact.since) | ||
| end | ||
|
|
||
| def test_area_with_single_quote | ||
| fb = Factbase.new | ||
| fb.txn do |fbt| | ||
| f = fbt.insert | ||
| f.what = 'pmp' | ||
| f.area = "te'st" | ||
| f.interval = 3 | ||
| end | ||
| loog = Loog::NULL | ||
| Fbe.regularly("te'st", 'interval', 'days', fb:, loog:, judge: 'test') do |f| | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Every test added by this PR uses Three cases that fail against this branch and should be in here:
The useful assertion is not |
||
| f.foo = 42 | ||
| end | ||
| assert_equal(2, fb.size) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This assertion is satisfied whether or not the escaped query matched anything, so it cannot fail for the reason the test exists. If the escaping mangled To assert the match, make the PMP Then a flag set inside the block (or |
||
| end | ||
|
|
||
| def test_judge_with_single_quote | ||
| fb = Factbase.new | ||
| fb.txn do |fbt| | ||
| f = fbt.insert | ||
| f.what = 'pmp' | ||
| f.area = 'quality' | ||
| f.interval = 3 | ||
| end | ||
| loog = Loog::NULL | ||
| Fbe.regularly('quality', 'interval', 'days', fb:, loog:, judge: "te'st") do |f| | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same gap as the area test, and here the fix is already written elsewhere in this file: The quoted-judge test should be that same loop with As written, one call and |
||
| f.foo = 42 | ||
| end | ||
| assert_equal(2, fb.size) | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -66,4 +66,22 @@ def test_failed_block_does_not_lock_out_next_run | |
| end | ||
| assert(ran) | ||
| end | ||
|
|
||
| def test_area_with_single_quote | ||
| fb = Factbase.new | ||
| $fb = fb | ||
| $loog = Loog::NULL | ||
| $options = Judges::Options.new | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Pass The globals are not merely redundant: nothing restores them, so they leak into whichever test runs next in the same process — |
||
| fb.txn do |fbt| | ||
| f = fbt.insert | ||
| f.what = 'pmp' | ||
| f.area = "te'st" | ||
| f.every_x_hours = 24 | ||
| end | ||
| $global = {} | ||
| Fbe.repeatedly("te'st", 'every_x_hours', fb:, judge: 'test') do |f| | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Three of the four escapes this PR adds to The commit message "remove judge test (Rules blocks it)" suggests the judge variant was dropped because of The 51/56 path is the one worth covering, because it is the asymmetric one — line 54 writes the raw judge string, lines 51 and 56 read it back through the escaped query. Call |
||
| f.foo = 42 | ||
| end | ||
| assert_equal(2, fb.size) | ||
| end | ||
| end | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Escaping
'alone does not close this hole, because factbase's tokenizer does not track which quote opened a literal. Infactbase-0.19.11/lib/factbase/syntax.rb:102-115the state is a single boolean,quotes = ['\'', '"']andstring = !stringis flipped by either character. A"inside a single-quoted literal therefore terminates it, and everything after it is parsed as query syntax.Concrete input:
area = 'x" (exists _id) (eq area "y'.gsub("'", ...)leaves it untouched, so this line builds(and (eq what 'pmp') (eq area 'x" (exists _id) (eq area "y') (exists interval)), which tokenizes to( and ( eq what 'pmp' ) ( eq area 'x' ) ( exists _id ) ( eq area 'y' ) ( exists interval ) )— two attacker-supplied terms inside the AST. A shorter payload,area = 'zzz" (always) (eq area "', instead tripsString literal can't be emptyand surfaces asFactbase::Syntax::Brokenout of the judge.The repo already contains a stronger version of this same escape:
lib/fbe/if_absent.rb:68andlib/fbe/just_one.rb:53do.gsub('"', '\\\\"').gsub("'", "\\\\'")— both quote characters. As written, this PR is weaker than escaping that already exists two files away. Minimum fix is to escape"as well; the better fix is one shared helper used by all of these sites.