flavour_saver 4.0.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -0
- data/Gemfile.lock +1 -1
- data/lib/flavour_saver/helpers.rb +15 -1
- data/lib/flavour_saver/runtime.rb +54 -0
- data/lib/flavour_saver/version.rb +1 -1
- data/spec/acceptance/ensure_no_rce_spec.rb +244 -16
- data/spec/fixtures/rce.hbs +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 453d625c7c8aad7ed0a3a06d21d482e92212e252e3a3e0624c825561cb117193
|
|
4
|
+
data.tar.gz: 2bae35d9e7f9c4330d93d4dbfdf72c2fb4d0ce09f660f9f80d78ff8095b249b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cab9b8e280389ec12e1dbb227698bb52e9ca30859fdb15103db7b5c35f7263e3d8355e05c94d364cda7d7278c7850155ac66ac9ef2027e617bde6ecf0e7387e6
|
|
7
|
+
data.tar.gz: c0d6c23e01a6850863092c1829310589e1fd6503f8337be215ad308d3487464cecf74330b4a42c29c937576a70b1b3e1f3a2f514adadfa3679c873006e417749
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## 4.0.1
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
* Fixed arbitrary code execution via template method dispatch
|
|
15
|
+
([GHSA-98g2-gr9f-f85r](https://github.com/FlavourSaver/FlavourSaver/security/advisories/GHSA-98g2-gr9f-f85r)).
|
|
16
|
+
A bare Handlebars identifier was dispatched to the rendering context with
|
|
17
|
+
`public_send`, and Ruby's metaprogramming methods are public on every object, so a
|
|
18
|
+
template containing `{{instance_eval "..."}}` executed arbitrary Ruby in the host
|
|
19
|
+
process. Anyone able to author or edit a template could run code as the application.
|
|
20
|
+
|
|
21
|
+
**All releases up to and including 4.0.0 are affected.** The fix for OSVDB #110796
|
|
22
|
+
guarded dispatch with `respond_to?`. That never blocked public `Object` methods, so
|
|
23
|
+
`{{instance_eval "..."}}` was reachable before and after it, and it could be sidestepped
|
|
24
|
+
entirely with `{{send "system" "ls"}}`. It stopped `{{system "ls"}}` only for context
|
|
25
|
+
objects that report their methods honestly — against a proxy or delegator that
|
|
26
|
+
overreports `respond_to?`, that payload kept working in every release. This release is
|
|
27
|
+
what closes it.
|
|
28
|
+
|
|
29
|
+
Reported by Arpit Jain ([@arpitjain099](https://github.com/arpitjain099)).
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
* Templates may now only dispatch to methods the application deliberately made
|
|
34
|
+
available: registered helpers, locals, FlavourSaver's own block helpers, and the
|
|
35
|
+
context object's own API. Anything else — Ruby's inherited object surface, methods
|
|
36
|
+
a framework has mixed into `Object`, and FlavourSaver's internal plumbing — raises
|
|
37
|
+
`FlavourSaver::ForbiddenMethodException`, a subclass of `UnknownHelperException`, so
|
|
38
|
+
existing rescue clauses continue to catch it.
|
|
39
|
+
|
|
40
|
+
Templates using `{{class}}`, `{{hash}}`, `{{method}}`, `{{display}}` and similar will
|
|
41
|
+
now raise instead of rendering. Such templates could not have been working correctly:
|
|
42
|
+
`Helpers::Decorator` inherits those methods, so its own implementation already shadowed
|
|
43
|
+
any same-named method on the context object — `{{hash}}` returned an object digest and
|
|
44
|
+
`{{display}}` printed the decorator to stdout rather than returning your data.
|
|
45
|
+
|
|
46
|
+
Context methods sharing a name with a *private* `Kernel` method — `format`, `open`,
|
|
47
|
+
`select`, `print`, `test`, `load` and around sixty others — are unaffected and continue
|
|
48
|
+
to dispatch as before.
|
|
49
|
+
|
|
50
|
+
To read a data field named after an `Object` method, use the segment literal syntax
|
|
51
|
+
(`{{[hash]}}`), which resolves through `Decorator#[]` and so needs a hash-like context,
|
|
52
|
+
or register an explicit helper.
|
|
53
|
+
|
|
10
54
|
## 4.0.0
|
|
11
55
|
|
|
12
56
|
### Added
|
data/Gemfile.lock
CHANGED
|
@@ -92,7 +92,21 @@ module FlavourSaver
|
|
|
92
92
|
# I would rather have it raise a NameError, but Moustache
|
|
93
93
|
# compatibility requires that missing helpers return
|
|
94
94
|
# nothing. A good place for bugs to hide.
|
|
95
|
-
|
|
95
|
+
#
|
|
96
|
+
# SECURITY: public_send here is load-bearing, not stylistic. Do not
|
|
97
|
+
# change it back to send.
|
|
98
|
+
#
|
|
99
|
+
# Private Kernel methods -- system, eval, exec, fork -- are deliberately
|
|
100
|
+
# not refused by Runtime#forbidden_method?, because refusing them would
|
|
101
|
+
# also refuse the ~60 context methods that share a name with one. They
|
|
102
|
+
# are safe because they cannot be reached: public_send skips them, so
|
|
103
|
+
# they arrive here, and this call skips them again.
|
|
104
|
+
#
|
|
105
|
+
# That makes this the only thing standing between a context that
|
|
106
|
+
# overreports respond_to? -- a proxy or delegator answering true to
|
|
107
|
+
# everything -- and arbitrary command execution. Reverting it to send
|
|
108
|
+
# reopens {{system "..."}} on such a context; verified, not theorised.
|
|
109
|
+
@source.public_send(name, *args, &b) if @source.respond_to? name
|
|
96
110
|
end
|
|
97
111
|
end
|
|
98
112
|
|
|
@@ -6,6 +6,7 @@ module FlavourSaver
|
|
|
6
6
|
InappropriateUseOfElseException = Class.new(StandardError)
|
|
7
7
|
UndefinedPrivateVariableException = Class.new(StandardError)
|
|
8
8
|
UnknownHelperException = Class.new(RuntimeError)
|
|
9
|
+
ForbiddenMethodException = Class.new(UnknownHelperException)
|
|
9
10
|
class Runtime
|
|
10
11
|
|
|
11
12
|
attr_accessor :context, :parent, :ast, :privates
|
|
@@ -128,6 +129,9 @@ module FlavourSaver
|
|
|
128
129
|
when LocalVarNode
|
|
129
130
|
result = private_variable_get(call.name)
|
|
130
131
|
else
|
|
132
|
+
if forbidden_method? context, call.name
|
|
133
|
+
raise ForbiddenMethodException, "Refusing to call #{call.name.inspect} from a template: it isn't a helper, a local, or part of the template context's own API. Register a helper if you meant to expose it, or use #{"{{[#{call.name}]}}"} to read a data field of that name."
|
|
134
|
+
end
|
|
131
135
|
if call.parent.is_a? BlockExpressionNode and !context.respond_to? call.name
|
|
132
136
|
raise UnknownHelperException, "Template context doesn't respond to method #{call.name.inspect}."
|
|
133
137
|
end
|
|
@@ -239,6 +243,56 @@ module FlavourSaver
|
|
|
239
243
|
|
|
240
244
|
private
|
|
241
245
|
|
|
246
|
+
# True unless +name+ resolves to something the application deliberately
|
|
247
|
+
# made available to templates.
|
|
248
|
+
#
|
|
249
|
+
# This is an allowlist of *owners* rather than a denylist of names, because
|
|
250
|
+
# a name denylist cannot be made complete. Object's inherited API is the
|
|
251
|
+
# obvious hazard (instance_eval, send, instance_exec), but it is not the
|
|
252
|
+
# whole of it: ActiveSupport mixes Object#try in from its own module and it
|
|
253
|
+
# forwards to public_send, and Decorator#method_missing is public and
|
|
254
|
+
# forwards to the context. Neither appears in Object.instance_methods.
|
|
255
|
+
#
|
|
256
|
+
# Only two things are legitimately dispatchable on the decorator itself:
|
|
257
|
+
# helpers and locals, which live on a module extended onto this runtime's
|
|
258
|
+
# decorator and so are absent from Decorator.ancestors; and FlavourSaver's
|
|
259
|
+
# own helpers, which Defaults owns. Everything else defined on the
|
|
260
|
+
# decorator is either Object's ambient surface or the decorator's internal
|
|
261
|
+
# plumbing, and neither is template surface.
|
|
262
|
+
#
|
|
263
|
+
# A name the decorator doesn't define at all raises NameError here, which
|
|
264
|
+
# means it will be delegated to the context object by method_missing --
|
|
265
|
+
# ordinary template dispatch, and allowed.
|
|
266
|
+
#
|
|
267
|
+
# Only *publicly* reachable names are refused. #method resolves private
|
|
268
|
+
# methods too, and refusing those as well would break every context method
|
|
269
|
+
# sharing a name with a private Kernel method -- format, open, select,
|
|
270
|
+
# print, test, load and some sixty others, all of which dispatch fine
|
|
271
|
+
# today. They aren't reachable through public_send in any case: they fall
|
|
272
|
+
# through to method_missing, which is guarded by its own use of
|
|
273
|
+
# public_send.
|
|
274
|
+
def forbidden_method?(context, name)
|
|
275
|
+
owner = begin
|
|
276
|
+
context.method(name).owner
|
|
277
|
+
rescue NameError
|
|
278
|
+
return false
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
return false if owner.equal? Helpers::Defaults
|
|
282
|
+
return false unless Helpers::Decorator.ancestors.include? owner
|
|
283
|
+
|
|
284
|
+
begin
|
|
285
|
+
context.singleton_class.public_method_defined? name
|
|
286
|
+
rescue TypeError
|
|
287
|
+
# Integer, Symbol and Float have no singleton class. A primitive can't
|
|
288
|
+
# carry a singleton method, so a name that resolved on one is
|
|
289
|
+
# necessarily inherited -- refuse it. Reached only if a primitive is
|
|
290
|
+
# ever passed here undecorated; the decorator wrapping in evaluate_call
|
|
291
|
+
# normally prevents that, but the predicate must be correct on its own.
|
|
292
|
+
true
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
242
296
|
def escape(output)
|
|
243
297
|
if output.respond_to?(:html_safe) && output.html_safe?
|
|
244
298
|
# If the string is already marked as html_safe then don't
|
|
@@ -1,26 +1,254 @@
|
|
|
1
1
|
require 'tilt'
|
|
2
|
+
require 'tmpdir'
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
# Object#try is mixed into Object from ActiveSupport's own module and forwards
|
|
5
|
+
# to public_send, so it is part of the surface under test here. Required
|
|
6
|
+
# explicitly rather than relying on another spec file having loaded it.
|
|
7
|
+
require 'active_support/core_ext/object/try'
|
|
2
8
|
require 'flavour_saver'
|
|
3
9
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
10
|
+
# These specs previously drove Tilt.new(template_string), which treats its
|
|
11
|
+
# argument as a *filename*. It raised "No template engine registered for
|
|
12
|
+
# {{system "ls"}}" before FlavourSaver was ever invoked, so both examples
|
|
13
|
+
# passed without executing a single line of the runtime. Drive
|
|
14
|
+
# FlavourSaver.evaluate directly so the dispatch path is actually exercised.
|
|
15
|
+
describe "Template-driven method dispatch" do
|
|
16
|
+
# A plain object, to show the context needs nothing special to be exploitable.
|
|
17
|
+
let(:context) { Object.new }
|
|
8
18
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
expect { subject }.to raise_error(RuntimeError)
|
|
19
|
+
def evaluate(template, ctx = context)
|
|
20
|
+
FlavourSaver.evaluate(template, ctx)
|
|
12
21
|
end
|
|
13
|
-
end
|
|
14
22
|
|
|
15
|
-
describe "
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
describe "refuses methods inherited from Object" do
|
|
24
|
+
{
|
|
25
|
+
'instance_eval' => '{{instance_eval "$fs_rce_canary = :pwned"}}',
|
|
26
|
+
'instance_exec' => '{{instance_exec "$fs_rce_canary = :pwned"}}',
|
|
27
|
+
'send' => '{{send "instance_eval" "$fs_rce_canary = :pwned"}}',
|
|
28
|
+
'__send__' => '{{__send__ "instance_eval" "$fs_rce_canary = :pwned"}}',
|
|
29
|
+
'public_send' => '{{public_send "instance_eval" "$fs_rce_canary = :pwned"}}',
|
|
30
|
+
'method' => '{{method "instance_eval"}}',
|
|
31
|
+
'object path form' => '{{this.instance_eval "$fs_rce_canary = :pwned"}}',
|
|
32
|
+
'subexpression form' => '{{log (instance_eval "$fs_rce_canary = :pwned")}}',
|
|
33
|
+
# Decorator#method_missing is public and forwards to the context, and
|
|
34
|
+
# method_missing is private on BasicObject so it never appeared in
|
|
35
|
+
# Object.instance_methods.
|
|
36
|
+
'method_missing' => '{{method_missing "instance_eval" "$fs_rce_canary = :pwned"}}',
|
|
37
|
+
}.each do |description, template|
|
|
38
|
+
it "refuses #{description}" do
|
|
39
|
+
$fs_rce_canary = nil
|
|
40
|
+
|
|
41
|
+
expect { evaluate(template) }.to raise_error(FlavourSaver::ForbiddenMethodException)
|
|
42
|
+
|
|
43
|
+
# Assert on the side effect too, not just the exception type: a payload
|
|
44
|
+
# that executes *and then* raises would satisfy raise_error alone.
|
|
45
|
+
expect($fs_rce_canary).to be_nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'refuses block expression form' do
|
|
50
|
+
$fs_rce_canary = nil
|
|
51
|
+
|
|
52
|
+
expect {
|
|
53
|
+
evaluate('{{#instance_eval "$fs_rce_canary = :pwned"}}x{{/instance_eval}}')
|
|
54
|
+
}.to raise_error(FlavourSaver::ForbiddenMethodException)
|
|
55
|
+
|
|
56
|
+
expect($fs_rce_canary).to be_nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'does not shell out via send' do
|
|
60
|
+
expect { evaluate(%q({{send "system" "echo pwned"}})) }
|
|
61
|
+
.to raise_error(FlavourSaver::ForbiddenMethodException)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'refuses regardless of the context object' do
|
|
65
|
+
[Object.new, { 'a' => 'b' }, Struct.new(:name).new('x'), 'a string', []].each do |ctx|
|
|
66
|
+
$fs_rce_canary = nil
|
|
67
|
+
|
|
68
|
+
expect { evaluate('{{instance_eval "$fs_rce_canary = :pwned"}}', ctx) }
|
|
69
|
+
.to raise_error(FlavourSaver::ForbiddenMethodException)
|
|
70
|
+
|
|
71
|
+
expect($fs_rce_canary).to be_nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Integer, Symbol and Float have no singleton class, so the visibility check
|
|
76
|
+
# raises TypeError rather than NameError. It must still resolve to a refusal
|
|
77
|
+
# (a primitive cannot carry a singleton method) rather than leaking an
|
|
78
|
+
# unrescued TypeError past the UnknownHelperException contract.
|
|
79
|
+
it 'refuses on primitive contexts without leaking TypeError' do
|
|
80
|
+
[5, :sym, 1.5].each do |ctx|
|
|
81
|
+
$fs_rce_canary = nil
|
|
82
|
+
|
|
83
|
+
expect { evaluate('{{instance_eval "$fs_rce_canary = :pwned"}}', ctx) }
|
|
84
|
+
.to raise_error(FlavourSaver::ForbiddenMethodException)
|
|
85
|
+
|
|
86
|
+
expect($fs_rce_canary).to be_nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The private forbidden_method? predicate must be correct on its own, not
|
|
91
|
+
# only behind the decorator wrapping in evaluate_call, since a primitive
|
|
92
|
+
# reaching it undecorated is the exact case that raised TypeError.
|
|
93
|
+
it 'the predicate refuses inherited methods on an undecorated primitive' do
|
|
94
|
+
runtime = FlavourSaver::Runtime.new(FlavourSaver.parse(FlavourSaver.lex('')), context)
|
|
95
|
+
expect(runtime.send(:forbidden_method?, 5, 'instance_eval')).to be true
|
|
96
|
+
# ...but leaves a primitive's own domain method dispatchable.
|
|
97
|
+
expect(runtime.send(:forbidden_method?, 5, 'bit_length')).to be false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'is catchable as UnknownHelperException, for existing rescue clauses' do
|
|
101
|
+
expect { evaluate('{{instance_eval "1"}}') }
|
|
102
|
+
.to raise_error(FlavourSaver::UnknownHelperException)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Helpers.decorate_with only mixes in the helpers named in the runtime's
|
|
106
|
+
# helper list when that list is non-empty. A guard that consulted the global
|
|
107
|
+
# registry would exempt a name that was never mixed in, and dispatch would
|
|
108
|
+
# reach Object's implementation of it.
|
|
109
|
+
it 'refuses a registered helper name that was scoped out of this runtime' do
|
|
110
|
+
$fs_rce_canary = nil
|
|
111
|
+
FlavourSaver::Helpers.register_helper(:send) { 'helper!' }
|
|
112
|
+
|
|
113
|
+
ast = FlavourSaver.parse(FlavourSaver.lex('{{send "instance_eval" "$fs_rce_canary = :pwned"}}'))
|
|
114
|
+
|
|
115
|
+
expect { FlavourSaver::Runtime.new(ast, context, {}, [:this]).to_s }
|
|
116
|
+
.to raise_error(FlavourSaver::ForbiddenMethodException)
|
|
117
|
+
|
|
118
|
+
expect($fs_rce_canary).to be_nil
|
|
119
|
+
ensure
|
|
120
|
+
FlavourSaver::Helpers.deregister_helper(:send)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Private Kernel methods are not refused by name -- doing so would break the
|
|
124
|
+
# ~60 context methods that share a name with one (format, open, select...).
|
|
125
|
+
# They are unreachable instead: public_send cannot call them, so they fall
|
|
126
|
+
# through to Decorator#method_missing, which also uses public_send. These
|
|
127
|
+
# assert non-execution rather than an exception type, because which error
|
|
128
|
+
# surfaces depends on the context object.
|
|
129
|
+
describe "private Kernel methods are unreachable" do
|
|
130
|
+
def expect_no_execution(template, ctx)
|
|
131
|
+
canary = File.join(Dir.tmpdir, "fs_rce_#{Process.pid}_#{rand(1 << 32)}")
|
|
132
|
+
begin
|
|
133
|
+
evaluate(format(template, canary), ctx)
|
|
134
|
+
rescue StandardError
|
|
135
|
+
# An exception is an acceptable outcome; execution is not.
|
|
136
|
+
end
|
|
137
|
+
expect(File.exist?(canary)).to be false
|
|
138
|
+
ensure
|
|
139
|
+
FileUtils.rm_f(canary)
|
|
140
|
+
end
|
|
19
141
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
142
|
+
%w[system eval exec fork spawn require load syscall].each do |name|
|
|
143
|
+
it "does not execute #{name}" do
|
|
144
|
+
expect_no_execution(%({{#{name} "touch %s"}}), context)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The OSVDB-110796 payload: a proxy or delegator answering true to
|
|
149
|
+
# everything used to make every private Kernel method reachable.
|
|
150
|
+
it 'does not execute against a context with a permissive respond_to?' do
|
|
151
|
+
permissive = Class.new { def respond_to?(name, priv = false); true; end }.new
|
|
152
|
+
expect_no_execution('{{system "touch %s"}}', permissive)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it 'does not execute via the send trampoline' do
|
|
156
|
+
expect_no_execution('{{send "system" "touch %s"}}', context)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Object#try forwards to public_send, so a guard keyed on the method's owner
|
|
161
|
+
# being Object/Kernel/BasicObject would miss it: ActiveSupport mixes try into
|
|
162
|
+
# Object via its own module.
|
|
163
|
+
it 'refuses methods mixed into Object by a framework' do
|
|
164
|
+
$fs_rce_canary = nil
|
|
165
|
+
|
|
166
|
+
expect { evaluate('{{try "instance_eval" "$fs_rce_canary = :pwned"}}') }
|
|
167
|
+
.to raise_error(FlavourSaver::ForbiddenMethodException)
|
|
168
|
+
|
|
169
|
+
expect($fs_rce_canary).to be_nil
|
|
170
|
+
end
|
|
23
171
|
end
|
|
24
|
-
end
|
|
25
172
|
|
|
173
|
+
# Named coverage of the dangerous surface. These are behavioural rather than
|
|
174
|
+
# assertions about an internal list, so they keep their meaning regardless of
|
|
175
|
+
# how the guard is implemented.
|
|
176
|
+
describe "refuses each dangerous name individually" do
|
|
177
|
+
# No predicate names here: the lexer's IDENT rule is /([A-Za-z_]\w*)/, so a
|
|
178
|
+
# trailing ? cannot form part of a template identifier.
|
|
179
|
+
%w[instance_eval instance_exec send __send__ public_send method
|
|
180
|
+
define_singleton_method singleton_class class extend freeze
|
|
181
|
+
method_missing tap then itself].each do |name|
|
|
182
|
+
it "refuses #{name}" do
|
|
183
|
+
expect { evaluate("{{#{name}}}") }
|
|
184
|
+
.to raise_error(FlavourSaver::ForbiddenMethodException)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Templates loaded from disk go through Tilt and FlavourSaver::Template rather
|
|
190
|
+
# than FlavourSaver.evaluate, so the refusal is asserted on that path too. This
|
|
191
|
+
# is what the original specs were reaching for: they passed the template *source*
|
|
192
|
+
# to Tilt.new, which expects a filename, so no engine matched and the whole
|
|
193
|
+
# suite short-circuited. Point it at a real .hbs fixture, as the other
|
|
194
|
+
# fixture specs do.
|
|
195
|
+
describe "refuses payloads in templates loaded from disk" do
|
|
196
|
+
let(:fixture) { File.expand_path('../../fixtures/rce.hbs', __FILE__) }
|
|
197
|
+
|
|
198
|
+
it 'refuses when rendered through Tilt' do
|
|
199
|
+
$fs_rce_canary = nil
|
|
200
|
+
|
|
201
|
+
expect { Tilt.new(fixture).render(context) }
|
|
202
|
+
.to raise_error(FlavourSaver::ForbiddenMethodException)
|
|
26
203
|
|
|
204
|
+
expect($fs_rce_canary).to be_nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
it 'has a fixture that really does contain a payload' do
|
|
208
|
+
expect(File.read(fixture)).to include 'instance_eval'
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The fix must not be so broad that it breaks ordinary templates.
|
|
213
|
+
describe "still allows legitimate dispatch" do
|
|
214
|
+
it 'calls a method the context actually defines' do
|
|
215
|
+
klass = Class.new { def greeting; 'hello'; end }
|
|
216
|
+
expect(evaluate('{{greeting}}', klass.new)).to eq 'hello'
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it 'reads a data field named after an Object method via segment literals' do
|
|
220
|
+
expect(evaluate('{{[class]}}', { 'class' => 'btn-primary' })).to eq 'btn-primary'
|
|
221
|
+
expect(evaluate('{{[hash]}}', { 'hash' => 'abc123' })).to eq 'abc123'
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it 'allows a deliberately registered helper to shadow an Object method' do
|
|
225
|
+
FlavourSaver::Helpers.register_helper(:hash) { 'abc123' }
|
|
226
|
+
expect(evaluate('{{hash}}')).to eq 'abc123'
|
|
227
|
+
ensure
|
|
228
|
+
FlavourSaver::Helpers.deregister_helper(:hash)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
it 'allows a local to shadow an Object method' do
|
|
232
|
+
runtime = FlavourSaver::Runtime.new(
|
|
233
|
+
FlavourSaver.parse(FlavourSaver.lex('{{hash}}')),
|
|
234
|
+
context,
|
|
235
|
+
{ 'hash' => proc { 'abc123' } }
|
|
236
|
+
)
|
|
237
|
+
expect(runtime.to_s).to eq 'abc123'
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
it 'still renders nothing for an unknown, non-forbidden method' do
|
|
241
|
+
expect(evaluate('{{no_such_method}}')).to eq ''
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Roughly sixty lexable names collide with a private Kernel method. They are
|
|
245
|
+
# plausible domain names and dispatched fine before 4.0.1, so refusing them
|
|
246
|
+
# would be a breaking change smuggled into a security patch.
|
|
247
|
+
%w[format open select print test load p raise loop gets rand warn catch].each do |name|
|
|
248
|
+
it "dispatches a context method named #{name}" do
|
|
249
|
+
klass = Class.new { define_method(name) { "value-#{name}" } }
|
|
250
|
+
expect(evaluate("{{#{name}}}", klass.new)).to eq "value-#{name}"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{{instance_eval "$fs_rce_canary = :pwned"}}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: flavour_saver
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.0.
|
|
4
|
+
version: 4.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Clayton Passmore
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: bin
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2026-
|
|
12
|
+
date: 2026-08-05 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: rake
|
|
@@ -133,6 +133,7 @@ files:
|
|
|
133
133
|
- spec/fixtures/multi_level_with.hbs
|
|
134
134
|
- spec/fixtures/one_character_identifier.hbs
|
|
135
135
|
- spec/fixtures/raw.hbs
|
|
136
|
+
- spec/fixtures/rce.hbs
|
|
136
137
|
- spec/fixtures/sections.hbs
|
|
137
138
|
- spec/fixtures/simple_expression.hbs
|
|
138
139
|
- spec/fixtures/unless.hbs
|
|
@@ -193,6 +194,7 @@ test_files:
|
|
|
193
194
|
- spec/fixtures/multi_level_with.hbs
|
|
194
195
|
- spec/fixtures/one_character_identifier.hbs
|
|
195
196
|
- spec/fixtures/raw.hbs
|
|
197
|
+
- spec/fixtures/rce.hbs
|
|
196
198
|
- spec/fixtures/sections.hbs
|
|
197
199
|
- spec/fixtures/simple_expression.hbs
|
|
198
200
|
- spec/fixtures/unless.hbs
|