Skip to content

Commit 6381eb1

Browse files
committed
Merge pull request #41 from zendesk/dasch/secure-compilation
Use a string scanner to parse the templates
2 parents 8de5d9c + 5dd1d7b commit 6381eb1

File tree

4 files changed

+241
-63
lines changed

4 files changed

+241
-63
lines changed

lib/curly/compiler.rb

Lines changed: 70 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,92 @@
1+
require 'curly/scanner'
12
require 'curly/invalid_reference'
23

34
module Curly
4-
class Compiler
5-
REFERENCE_REGEX = %r(\{\{([\w\.]+)\}\})
6-
COMMENT_REGEX = %r(\{\{\!\s*(.*)\s*\}\})
7-
COMMENT_LINE_REGEX = %r(^\s*#{COMMENT_REGEX}\s*\n)
8-
9-
class << self
105

11-
# Compiles a Curly template to Ruby code.
12-
#
13-
# template - The template String that should be compiled.
14-
#
15-
# Returns a String containing the Ruby code.
16-
def compile(template, presenter_class)
17-
if presenter_class.nil?
18-
raise ArgumentError, "presenter class cannot be nil"
19-
end
6+
# Compiles Curly templates into executable Ruby code.
7+
#
8+
# A template must be accompanied by a presenter class. This class defines the
9+
# references that are valid within the template.
10+
#
11+
class Compiler
12+
# Compiles a Curly template to Ruby code.
13+
#
14+
# template - The template String that should be compiled.
15+
# presenter_class - The presenter Class.
16+
#
17+
# Returns a String containing the Ruby code.
18+
def self.compile(template, presenter_class)
19+
new(template, presenter_class).compile
20+
end
2021

21-
source = template.dup
22+
# Whether the Curly template is valid. This includes whether all
23+
# references are available on the presenter class.
24+
#
25+
# template - The template String that should be validated.
26+
# presenter_class - The presenter Class.
27+
#
28+
# Returns true if the template is valid, false otherwise.
29+
def self.valid?(template, presenter_class)
30+
compile(template, presenter_class)
31+
32+
true
33+
rescue InvalidReference
34+
false
35+
end
2236

23-
# Escape double quotes.
24-
source.gsub!('"', '\"')
37+
attr_reader :template, :presenter_class
2538

26-
source.gsub!(REFERENCE_REGEX) { compile_reference($1, presenter_class) }
27-
source.gsub!(COMMENT_LINE_REGEX) { compile_comment_line($1) }
28-
source.gsub!(COMMENT_REGEX) { compile_comment($1) }
39+
def initialize(template, presenter_class)
40+
@template, @presenter_class = template, presenter_class
41+
end
2942

30-
'"%s"' % source
43+
def compile
44+
if presenter_class.nil?
45+
raise ArgumentError, "presenter class cannot be nil"
3146
end
3247

33-
# Whether the Curly template is valid. This includes whether all
34-
# references are available on the presenter class.
35-
#
36-
# template - The template String that should be validated.
37-
# presenter_class - The presenter Class.
38-
#
39-
# Returns true if the template is valid, false otherwise.
40-
def valid?(template, presenter_class)
41-
compile(template, presenter_class)
42-
43-
true
44-
rescue InvalidReference
45-
false
46-
end
48+
tokens = Scanner.scan(template)
4749

48-
private
50+
parts = tokens.map do |type, value|
51+
send("compile_#{type}", value)
52+
end
4953

50-
def compile_reference(reference, presenter_class)
51-
method, argument = reference.split(".", 2)
54+
parts.join(" << ")
55+
end
5256

53-
unless presenter_class.method_available?(method.to_sym)
54-
raise Curly::InvalidReference.new(method.to_sym)
55-
end
57+
private
5658

57-
if presenter_class.instance_method(method).arity == 1
58-
# The method accepts a single argument -- pass it in.
59-
code = <<-RUBY
60-
presenter.#{method}(#{argument.inspect}) {|*args| yield(*args) }
61-
RUBY
62-
else
63-
code = <<-RUBY
64-
presenter.#{method} {|*args| yield(*args) }
65-
RUBY
66-
end
59+
def compile_reference(reference)
60+
method, argument = reference.split(".", 2)
6761

68-
'#{ERB::Util.html_escape(%s)}' % code.strip
62+
unless presenter_class.method_available?(method.to_sym)
63+
raise Curly::InvalidReference.new(method.to_sym)
6964
end
7065

71-
def compile_comment_line(comment)
72-
"" # Replace the content with an empty string.
66+
if presenter_class.instance_method(method).arity == 1
67+
# The method accepts a single argument -- pass it in.
68+
code = <<-RUBY
69+
presenter.#{method}(#{argument.inspect}) {|*args| yield(*args) }
70+
RUBY
71+
else
72+
code = <<-RUBY
73+
presenter.#{method} {|*args| yield(*args) }
74+
RUBY
7375
end
7476

75-
def compile_comment(comment)
76-
"" # Replace the content with an empty string.
77-
end
77+
'ERB::Util.html_escape(%s)' % code.strip
78+
end
79+
80+
def compile_text(text)
81+
text.inspect
82+
end
83+
84+
def compile_comment_line(comment)
85+
"''" # Replace the content with an empty string.
86+
end
87+
88+
def compile_comment(comment)
89+
"''" # Replace the content with an empty string.
7890
end
7991
end
8092
end

lib/curly/scanner.rb

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
require 'strscan'
2+
3+
module Curly
4+
5+
# Scans Curly templates for tokens.
6+
#
7+
# The Scanner goes through the template piece by piece, extracting tokens
8+
# until the end of the template is reached.
9+
#
10+
class Scanner
11+
REFERENCE_REGEX = %r(\{\{[\w\.]+\}\})
12+
COMMENT_REGEX = %r(\{\{!.*\}\})
13+
COMMENT_LINE_REGEX = %r(\s*#{COMMENT_REGEX}\s*\n)
14+
15+
# Scans a Curly template for tokens.
16+
#
17+
# source - The String source of the template.
18+
#
19+
# Example
20+
#
21+
# Curly::Scanner.scan("hello {{name}}!")
22+
# #=> [[:text, "hello "], [:reference, "name"], [:text, "!"]]
23+
#
24+
# Returns an Array of type/value pairs representing the tokens in the
25+
# template.
26+
def self.scan(source)
27+
new(source).scan
28+
end
29+
30+
def initialize(source)
31+
@scanner = StringScanner.new(source)
32+
end
33+
34+
def scan
35+
tokens = []
36+
tokens << scan_token until @scanner.eos?
37+
tokens
38+
end
39+
40+
private
41+
42+
# Scans the next token in the template.
43+
#
44+
# Returns a two-element Array, the first element being the Symbol type of
45+
# the token and the second being the String value.
46+
def scan_token
47+
scan_reference ||
48+
scan_comment_line ||
49+
scan_comment ||
50+
scan_text ||
51+
scan_remainder
52+
end
53+
54+
# Scans a reference token, if a reference is the next token in the template.
55+
#
56+
# Returns an Array representing the token, or nil if no reference token can
57+
# be found at the current position.
58+
def scan_reference
59+
if value = @scanner.scan(REFERENCE_REGEX)
60+
# Return the reference name excluding "{{" and "}}".
61+
[:reference, value[2..-3]]
62+
end
63+
end
64+
65+
# Scans a comment line token, if a comment line is the next token in the
66+
# template.
67+
#
68+
# Returns an Array representing the token, or nil if no comment line token
69+
# can be found at the current position.
70+
def scan_comment_line
71+
if value = @scanner.scan(COMMENT_LINE_REGEX)
72+
# Returns the comment excluding "{{!" and "}}".
73+
[:comment_line, value[3..-4]]
74+
end
75+
end
76+
77+
# Scans a comment token, if a comment is the next token in the template.
78+
#
79+
# Returns an Array representing the token, or nil if no comment token can
80+
# be found at the current position.
81+
def scan_comment
82+
if value = @scanner.scan(COMMENT_REGEX)
83+
# Returns the comment excluding "{{!" and "}}".
84+
[:comment, value[3..-3]]
85+
end
86+
end
87+
88+
# Scans a text token, if a text is the next token in the template.
89+
#
90+
# Returns an Array representing the token, or nil if no text token can
91+
# be found at the current position.
92+
def scan_text
93+
if value = @scanner.scan_until(/\{\{/)
94+
# Rewind the scanner until before the "{{"
95+
@scanner.pos -= 2
96+
97+
# Return the text up until "{{".
98+
[:text, value[0..-3]]
99+
end
100+
end
101+
102+
# Scans the remainder of the template and treats it as a text token.
103+
#
104+
# Returns an Array representing the token, or nil if no text is remaining.
105+
def scan_remainder
106+
if value = @scanner.scan(/.+/m)
107+
[:text, value]
108+
end
109+
end
110+
end
111+
end

spec/compiler_spec.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,6 @@ def method_missing(*args)
8585
evaluate("{{yield_value}}") {|v| v.upcase }.should == "FOO, please?"
8686
end
8787

88-
it "properly handles quotes in the template" do
89-
evaluate('"').should == '"'
90-
end
91-
9288
it "escapes non HTML safe strings returned from the presenter" do
9389
presenter.stub(:dirty) { "<p>dirty</p>" }
9490
evaluate("{{dirty}}").should == "&lt;p&gt;dirty&lt;/p&gt;"
@@ -106,10 +102,14 @@ def method_missing(*args)
106102
it "removes comment lines from the output" do
107103
evaluate(<<-CURLY.strip_heredoc).should == "HELO\nWORLD\n"
108104
HELO
109-
{{! I'm a comment }}
105+
{{! I'm a comment }}
110106
WORLD
111107
CURLY
112108
end
109+
110+
it "does not execute arbitrary Ruby code" do
111+
evaluate('#{foo}').should == '#{foo}'
112+
end
113113
end
114114

115115
describe ".valid?" do

spec/scanner_spec.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
require 'spec_helper'
2+
3+
describe Curly::Scanner, ".scan" do
4+
it "returns the tokens in the source" do
5+
scan("foo {{bar}} baz").should == [
6+
[:text, "foo "],
7+
[:reference, "bar"],
8+
[:text, " baz"]
9+
]
10+
end
11+
12+
it "scans parameterized references" do
13+
scan("{{foo.bar}}").should == [
14+
[:reference, "foo.bar"]
15+
]
16+
end
17+
18+
it "scans comments in the source" do
19+
scan("foo {{!bar}} baz").should == [
20+
[:text, "foo "],
21+
[:comment, "bar"],
22+
[:text, " baz"]
23+
]
24+
end
25+
26+
it "scans comment lines in the source" do
27+
scan("foo\n{{!bar}}\nbaz").should == [
28+
[:text, "foo\n"],
29+
[:comment_line, "bar"],
30+
[:text, "baz"]
31+
]
32+
end
33+
34+
it "scans to the end of the source" do
35+
scan("foo\n").should == [
36+
[:text, "foo\n"]
37+
]
38+
end
39+
40+
it "treats quotes as text" do
41+
scan('"').should == [
42+
[:text, '"']
43+
]
44+
end
45+
46+
it "treats Ruby interpolation as text" do
47+
scan('#{foo}').should == [
48+
[:text, '#{foo}']
49+
]
50+
end
51+
52+
def scan(source)
53+
Curly::Scanner.scan(source)
54+
end
55+
end

0 commit comments

Comments
 (0)