summaryrefslogtreecommitdiff
path: root/lib/gitlab/changelog/template
diff options
context:
space:
mode:
Diffstat (limited to 'lib/gitlab/changelog/template')
-rw-r--r--lib/gitlab/changelog/template/compiler.rb154
-rw-r--r--lib/gitlab/changelog/template/context.rb70
-rw-r--r--lib/gitlab/changelog/template/template.rb29
3 files changed, 253 insertions, 0 deletions
diff --git a/lib/gitlab/changelog/template/compiler.rb b/lib/gitlab/changelog/template/compiler.rb
new file mode 100644
index 00000000000..fa7724aa2da
--- /dev/null
+++ b/lib/gitlab/changelog/template/compiler.rb
@@ -0,0 +1,154 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Changelog
+ module Template
+ # Compiler is used for turning a minimal user templating language into an
+ # ERB template, without giving the user access to run arbitrary code.
+ #
+ # The template syntax is deliberately made as minimal as possible, and
+ # only supports the following:
+ #
+ # * Printing a value
+ # * Iterating over collections
+ # * if/else
+ #
+ # The syntax looks as follows:
+ #
+ # {% each users %}
+ #
+ # Name: {{user}}
+ # Likes cats: {% if likes_cats %}yes{% else %}no{% end %}
+ #
+ # {% end %}
+ #
+ # Newlines can be escaped by ending a line with a backslash. So this:
+ #
+ # foo \
+ # bar
+ #
+ # Is the same as this:
+ #
+ # foo bar
+ #
+ # Templates are compiled into ERB templates, while taking care to make
+ # sure the user can't run arbitrary code. By using ERB we can let it do
+ # the heavy lifting of rendering data; all we need to provide is a
+ # translation layer.
+ #
+ # # Security
+ #
+ # The template syntax this compiler exposes is safe to be used by
+ # untrusted users. Not only are they unable to run arbitrary code, the
+ # compiler also enforces a limit on the integer sizes and the number of
+ # nested loops. ERB tags added by the user are also disabled.
+ class Compiler
+ # A pattern to match a single integer, with an upper size limit.
+ #
+ # We enforce a limit of 10 digits (= a 32 bits integer) so users can't
+ # trigger the allocation of infinitely large bignums, or trigger
+ # RangeError errors when using such integers to access an array value.
+ INTEGER = /^\d{1,10}$/.freeze
+
+ # The name/path of a variable, such as `user.address.city`.
+ #
+ # It's important that this regular expression _doesn't_ allow for
+ # anything but letters, numbers, and underscores, otherwise a user may
+ # use those to "escape" our template and run arbirtary Ruby code. For
+ # example, take this variable:
+ #
+ # {{') ::Kernel.exit #'}}
+ #
+ # This would then be compiled into:
+ #
+ # <%= read(variables, '') ::Kernel.exit #'') %>
+ #
+ # Restricting the allowed characters makes this impossible.
+ VAR_NAME = /([\w\.]+)/.freeze
+
+ # A variable tag, such as `{{username}}`.
+ VAR = /{{ \s* #{VAR_NAME} \s* }}/x.freeze
+
+ # The opening tag for a statement.
+ STM_START = /{% \s*/x.freeze
+
+ # The closing tag for a statement.
+ STM_END = /\s* %}/x.freeze
+
+ # A regular `end` closing tag.
+ NORMAL_END = /#{STM_START} end #{STM_END}/x.freeze
+
+ # An `end` closing tag on its own line, without any non-whitespace
+ # preceding or following it.
+ #
+ # These tags need some special care to make it easier to control
+ # whitespace.
+ LONELY_END = /^\s*#{NORMAL_END}\s$/x.freeze
+
+ # An `else` tag.
+ ELSE = /#{STM_START} else #{STM_END}/x.freeze
+
+ # The start of an `each` tag.
+ EACH = /#{STM_START} each \s+ #{VAR_NAME} #{STM_END}/x.freeze
+
+ # The start of an `if` tag.
+ IF = /#{STM_START} if \s+ #{VAR_NAME} #{STM_END}/x.freeze
+
+ # The pattern to use for escaping newlines.
+ ESCAPED_NEWLINE = /\\\n$/.freeze
+
+ # The start tag for ERB tags. These tags will be escaped, preventing
+ # users from using ERB directly.
+ ERB_START_TAG = /<\\?\s*\\?\s*%/.freeze
+
+ def compile(template)
+ transformed_lines = ['<% it = variables %>']
+
+ # ERB tags must be stripped here, otherwise a user may introduce ERB
+ # tags by making clever use of whitespace. See
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/300224 for more
+ # information.
+ template = template.gsub(ERB_START_TAG, '<%%')
+
+ template.each_line { |line| transformed_lines << transform(line) }
+
+ # We use the full namespace here as otherwise Rails may use the wrong
+ # constant when autoloading is used.
+ ::Gitlab::Changelog::Template::Template.new(transformed_lines.join)
+ end
+
+ def transform(line)
+ line.gsub!(ESCAPED_NEWLINE, '')
+
+ # This replacement ensures that "end" blocks on their own lines
+ # don't add extra newlines. Using an ERB -%> tag sadly swallows too
+ # many newlines.
+ line.gsub!(LONELY_END, '<% end %>')
+ line.gsub!(NORMAL_END, '<% end %>')
+ line.gsub!(ELSE, '<% else -%>')
+
+ line.gsub!(EACH) do
+ # No, `it; variables` isn't a syntax error. Using `;` marks
+ # `variables` as block-local, making it possible to re-assign it
+ # without affecting outer definitions of this variable. We use
+ # this to scope template variables to the right input Hash.
+ "<% each(#{read_path(Regexp.last_match(1))}) do |it; variables| -%><% variables = it -%>"
+ end
+
+ line.gsub!(IF) { "<% if truthy?(#{read_path(Regexp.last_match(1))}) -%>" }
+ line.gsub!(VAR) { "<%= #{read_path(Regexp.last_match(1))} %>" }
+ line
+ end
+
+ def read_path(path)
+ return path if path == 'it'
+
+ args = path.split('.')
+ args.map! { |arg| arg.match?(INTEGER) ? "#{arg}" : "'#{arg}'" }
+
+ "read(variables, #{args.join(', ')})"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/changelog/template/context.rb b/lib/gitlab/changelog/template/context.rb
new file mode 100644
index 00000000000..8a0796d767e
--- /dev/null
+++ b/lib/gitlab/changelog/template/context.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Changelog
+ module Template
+ # Context is used to provide a binding/context to ERB templates used for
+ # rendering changelogs.
+ #
+ # This class extends BasicObject so that we only expose the bare minimum
+ # needed to render the ERB template.
+ class Context < BasicObject
+ MAX_NESTED_LOOPS = 4
+
+ def initialize(variables)
+ @variables = variables
+ @loop_nesting = 0
+ end
+
+ def get_binding
+ ::Kernel.binding
+ end
+
+ def each(value, &block)
+ max = MAX_NESTED_LOOPS
+
+ if @loop_nesting == max
+ ::Kernel.raise(
+ ::Template::TemplateError.new("You can only nest up to #{max} loops")
+ )
+ end
+
+ @loop_nesting += 1
+ result = value.each(&block) if value.respond_to?(:each)
+ @loop_nesting -= 1
+
+ result
+ end
+
+ # rubocop: disable Style/TrivialAccessors
+ def variables
+ @variables
+ end
+ # rubocop: enable Style/TrivialAccessors
+
+ def read(source, *steps)
+ current = source
+
+ steps.each do |step|
+ case current
+ when ::Hash
+ current = current[step]
+ when ::Array
+ return '' unless step.is_a?(::Integer)
+
+ current = current[step]
+ else
+ break
+ end
+ end
+
+ current
+ end
+
+ def truthy?(value)
+ value.respond_to?(:any?) ? value.any? : !!value
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/changelog/template/template.rb b/lib/gitlab/changelog/template/template.rb
new file mode 100644
index 00000000000..0ff2852d6d4
--- /dev/null
+++ b/lib/gitlab/changelog/template/template.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Changelog
+ module Template
+ # A wrapper around an ERB template user for rendering changelogs.
+ class Template
+ TemplateError = Class.new(StandardError)
+
+ def initialize(erb)
+ # Don't change the trim mode, as this may require changes to the
+ # regular expressions used to turn the template syntax into ERB
+ # tags.
+ @erb = ERB.new(erb, trim_mode: '-')
+ end
+
+ def render(data)
+ context = Context.new(data).get_binding
+
+ # ERB produces a SyntaxError when processing templates, as it
+ # internally uses eval() for this.
+ @erb.result(context)
+ rescue SyntaxError
+ raise TemplateError.new("The template's syntax is invalid")
+ end
+ end
+ end
+ end
+end