Skip to content

Commit 83c920e

Browse files
authored
Fix XPath variable handling and invalid value contamination (#342)
Fixes #15 Fix nil and other invalid value contamination. Nil can be injected through variables and through id function. Unimplemented function `id()` is fixed to return `[]` instead of `nil`. Applies predicates/path evaluation if it exists after variable. Add variable validation (TypeError if invalid). Variable existence check (NameError if undefined variable exist in xpath even if it is not referenced) is excluded from this PR, and just fallbacks to "" for now. Minor behavior changes for an invalid XPath match: ```ruby doc = REXML::Document.new("<root/>") REXML::XPath.match(doc, '($x)[1<2]', {}, {'x'=>42}) #=> [42] → [] REXML::XPath.match(doc, '$x[1<2]', {}, {'x'=>42}) #=> [42] → [] ``` It may raise TypeError (because `$x` wasn't evaluated to a nodeset), though, it shoudn't return `[42]` ## Note Although nodeset as a variable has been accepted before, the code added in pull request explicitly permits it. Nokogiri only accepts string value as a variable, so there's an option to limit the value types here. Accepting nodeset may cause a problem if the passed nodes doesn't belong to a single document root. (or just consider such case as unsupported)
1 parent 755d8d5 commit 83c920e

3 files changed

Lines changed: 86 additions & 13 deletions

File tree

lib/rexml/functions.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def count( node_set )
5757
# Since REXML is non-validating, this method is not implemented as it
5858
# requires a DTD
5959
def id( object )
60+
[]
6061
end
6162

6263
def local_name(node_set=nil)

lib/rexml/xpath_parser.rb

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ def namespaces=( namespaces={} )
7575
@namespaces = namespaces
7676
end
7777

78-
def variables=( vars={} )
78+
def variables=(vars)
79+
vars = vars.transform_values { |v| coerce_variable(v) }
7980
@functions.variables = vars
8081
@variables = vars
8182
end
@@ -103,7 +104,7 @@ def predicate path, node
103104
end
104105

105106
def []=( variable_name, value )
106-
@variables[ variable_name ] = value
107+
@variables[variable_name] = coerce_variable(value)
107108
end
108109

109110

@@ -267,8 +268,10 @@ def expr( path_stack, nodeset, context=nil )
267268
end
268269
when :variable
269270
var_name = path_stack.shift
270-
return @variables[var_name]
271+
value = @variables.key?(var_name) ? @variables[var_name] : ""
272+
return value if path_stack.empty?
271273

274+
nodeset = apply_remaining_predicates(path_stack, value)
272275
when :eq, :neq, :lt, :lteq, :gt, :gteq
273276
left = expr( path_stack.shift, nodeset.dup, context )
274277
right = expr( path_stack.shift, nodeset.dup, context )
@@ -338,19 +341,16 @@ def expr( path_stack, nodeset, context=nil )
338341
expr(arg, nodeset, target_context)
339342
end
340343
@functions.context = target_context
341-
return @functions.send(func_name, *args)
344+
result = @functions.send(func_name, *args)
345+
return result if path_stack.empty?
346+
347+
nodeset = apply_remaining_predicates(path_stack, result)
342348
when :group
343349
sub_expression = path_stack.shift
344350
result = expr(sub_expression, nodeset, context)
345-
if result.is_a?(Array)
346-
# If result is a nodeset, apply following predicates
347-
path_stack.unshift(:node)
348-
nodeset = step(path_stack) do
349-
[:iterate_nodesets, [XPathParser.sort(result)]]
350-
end
351-
else
352-
return result
353-
end
351+
return result if path_stack.empty?
352+
353+
nodeset = apply_remaining_predicates(path_stack, result)
354354
else
355355
raise "[BUG] Unexpected path: <#{op.inspect}>: <#{path_stack.inspect}>"
356356
end
@@ -360,6 +360,34 @@ def expr( path_stack, nodeset, context=nil )
360360
leave(:expr, path_stack, nodeset) if @debug
361361
end
362362

363+
def apply_remaining_predicates(path_stack, value)
364+
value = [] unless value.is_a?(Array)
365+
path_stack.unshift(:node)
366+
step(path_stack) do
367+
[:iterate_nodesets, [XPathParser.sort(value)]]
368+
end
369+
end
370+
371+
# Validates and coerces a variable value to a type that can be used in XPath expressions.
372+
def coerce_variable(value)
373+
case value
374+
when REXML::Node
375+
[value]
376+
when Array
377+
unless value.all?(REXML::Node)
378+
raise TypeError, 'Array variable must contain only REXML::Node objects'
379+
end
380+
value.uniq
381+
when Numeric, String, true, false
382+
value
383+
when nil
384+
# Convert to an empty string for backward compatibility
385+
""
386+
else
387+
raise TypeError, "Unsupported variable type: #{value.class}"
388+
end
389+
end
390+
363391
# Determines if a predicate expression is dependent on the position of nodes.
364392
# Returns false if the expression is guaranteed to be position-independent.
365393
# Returns true if the expression might be position-dependent.

test/xpath/test_base.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1563,5 +1563,49 @@ def test_reverse_axis_function_argument_sort
15631563
assert_equal(["e"], XPath.match(doc, "//e[10 + preceding-sibling::* = 11]").map(&:name))
15641564
assert_equal(["e"], XPath.match(doc, "//e[preceding-sibling::* = '1']").map(&:name))
15651565
end
1566+
1567+
def test_unimplemented_id_should_not_contaminate_nil
1568+
doc = Document.new("<root/>")
1569+
assert_equal([], XPath.match(doc, 'id("foo")'))
1570+
assert_equal([], XPath.match(doc, 'id("foo")[1]'))
1571+
assert_equal([], XPath.match(doc, 'id("foo")/bar'))
1572+
end
1573+
1574+
def test_variables
1575+
doc = Document.new("<a><b><c/></b><d><e/></d></a>")
1576+
a, b, c, d, e = XPath.match(doc, '//*')
1577+
assert_equal([''], XPath.match(doc, '$y', nil, { 'x' => 1 }))
1578+
assert_raise(TypeError) { XPath.match(doc, '$x', nil, { 'x' => Object.new }) }
1579+
assert_raise(TypeError) { XPath.match(doc, '/a', nil, { 'x' => Object.new }) }
1580+
assert_raise(TypeError) { XPath.match(doc, '$x', nil, { 'x' => [a, Object.new, b] }) }
1581+
assert_equal([1], XPath.match(doc, '$x', nil, { 'x' => 1 }))
1582+
assert_equal([false], XPath.match(doc, '$x', nil, { 'x' => false }))
1583+
assert_equal([''], XPath.match(doc, '$x', nil, { 'x' => nil }))
1584+
assert_equal([3], XPath.match(doc, 'count($x)', nil, { 'x' => [b, c, d] }))
1585+
assert_equal([3], XPath.match(doc, 'count($x)', nil, { 'x' => [a, a, b, b, c, c] }))
1586+
assert_equal([b, c, d], XPath.match(doc, '$x', nil, { 'x' => [d, c, b] }))
1587+
assert_equal([a], XPath.match(doc, '//*[name()=$x]', nil, { 'x' => 'a' }))
1588+
assert_equal([c, e], XPath.match(doc, '$x/*', nil, { 'x' => [b, d] }))
1589+
assert_equal([c], XPath.match(doc, '$x/*', nil, { 'x' => [b] }))
1590+
assert_equal([c], XPath.match(doc, '$x/*', nil, { 'x' => b }))
1591+
end
1592+
1593+
def test_attribute_variables
1594+
doc = Document.new("<a a='1'><b a='1'/></a>")
1595+
a1, a2 = XPath.match(doc, '//attribute::*')
1596+
assert_equal([a1, a2], XPath.match(doc, '$x', nil, { 'x' => [a2, a1] }))
1597+
end
1598+
1599+
def test_variables_invalid_predicates
1600+
doc = Document.new("<root/>")
1601+
# Predicates after variable may be invalid depending on variable type.
1602+
# It can raise an exception such as TypeError, or treat the predicate result as an empty node set,
1603+
# but it should not return the variable value itself.
1604+
valid_result = [:exception, []]
1605+
actual = (XPath.match(doc, '$x[1<2]', nil, { 'x' => 42 }) rescue :exception)
1606+
assert_includes(valid_result, actual)
1607+
actual = (XPath.match(doc, '($x)[1<2]', nil, { 'x' => 42 }) rescue :exception)
1608+
assert_includes(valid_result, actual)
1609+
end
15661610
end
15671611
end

0 commit comments

Comments
 (0)