Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions _plugins/safe_post_html.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# frozen_string_literal: true
#
# Fails the build when a post's rendered body holds markup that would run code
# in a reader's browser or load another document in the site's origin: an
# active element (<script>, <object>, <base>, <meta>, <form>, ...), an event
# handler or srcdoc/formaction attribute, or a URL attribute whose scheme is
# not one a post links to (http(s), mailto, tel, a relative path or anchor, or
# an inline image).
#
# It checks what kramdown produced, after conversion and before the layout, so
# it sees every way Markdown can make an attribute (raw HTML, links, images,
# attribute lists) as the browser will read it: entities decoded, attributes
# split by an HTML5 parser (Nokogiri::HTML5, as browsers parse). Code blocks
# are text by then and are not flagged. Posts are generated and then reviewed
# as pull requests, so a failing build blocks the merge (and the deploy)
# instead of silently rewriting the post.
#
# This repo builds via GitHub Actions (not the github-pages gem sandbox), so
# custom _plugins are allowed.
begin
require "nokogiri"
rescue LoadError
# Plain `ruby test/run_all.rb` in CI: the gem is in the bundle's path.
require "bundler/setup"
require "nokogiri"
end

module SafePostHtml
ACTIVE_ELEMENTS = %w[
script style object embed applet base link meta form frame frameset noscript
].freeze
ACTIVE_ATTRIBUTES = %w[srcdoc formaction].freeze
URL_ATTRIBUTES = %w[
href src action data poster background cite xlink:href srcset
].freeze
SAFE_SCHEMES = %w[http https mailto tel].freeze

# Whether +url+ is one a post may carry. Spaces and control characters a
# browser ignores in a URL are ignored here too.
def self.safe_url?(url, element)
bare = url.to_s.gsub(/[\u0000- \u007f]/, "").downcase
scheme, colon, rest = bare.partition(":")
return true if colon.empty? || scheme.match?(%r{[/?#]})

return true if SAFE_SCHEMES.include?(scheme)

# An inline image: only where an image is loaded.
scheme == "data" && rest.start_with?("image/") && %w[img source].include?(element)
end

# The problems of a rendered post body, as "element attribute=value" lines.
def self.violations(html)
found = []
Nokogiri::HTML5::DocumentFragment.parse(html.to_s).traverse do |node|
next unless node.element?

name = node.name.downcase
found << "<#{name}>" if ACTIVE_ELEMENTS.include?(name)
node.attribute_nodes.each do |attr|
key = attr.name.downcase
if key.start_with?("on") || ACTIVE_ATTRIBUTES.include?(key)
found << "<#{name} #{key}>"
elsif URL_ATTRIBUTES.include?(key)
urls = key == "srcset" ? attr.value.split(",").map { |u| u.strip.split(/\s+/).first.to_s } : [attr.value]
urls.each do |url|
found << "<#{name} #{key}=\"#{url[0, 80]}\">" unless safe_url?(url, name)
end
end
end
end
found.uniq
end
end

# Guarded so test/ can require this file for SafePostHtml.violations without
# Jekyll loaded, and without registering the hook.
if defined?(Jekyll::Hooks)
Jekyll::Hooks.register :posts, :post_convert do |post|
problems = SafePostHtml.violations(post.content)
next if problems.empty?

raise Jekyll::Errors::FatalException,
"#{post.relative_path}: markup that runs code or loads a document " \
"(#{problems.first(5).join(', ')}). Remove it or put it in a code block."
end
end
56 changes: 56 additions & 0 deletions test/test_safe_post_html.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true
#
# Unit tests for _plugins/safe_post_html.rb.
#
# The check runs over every post's rendered body and fails the build, so it must
# catch each way markup can run code (as the browser reads it) and never flag
# what posts legitimately carry: images, links, video embeds, code blocks.

require "minitest/autorun"
require_relative "../_plugins/safe_post_html"

class TestSafePostHtml < Minitest::Test
ACTIVE = [
"<script>alert(1)</script>",
'<img src="x" onerror="alert(1)">',
"<svg onload=alert(1)></svg>",
'<a href="javascript:alert(1)">x</a>',
'<a href="java&#x73;cript:alert(1)">x</a>',
"<a href=\"java\tscript:alert(1)\">x</a>",
'<a href="JavaScript&colon;alert(1)">x</a>',
'<iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;"></iframe>',
'<iframe src="data:text/html,x"></iframe>',
"<object data=\"x.swf\"></object>",
'<form action="https://evil.example"><button>b</button></form>',
'<button formaction="javascript:alert(1)">b</button>',
'<meta http-equiv="refresh" content="0;url=https://evil.example">',
'<base href="https://evil.example/">',
"<p onmouseover=\"alert(1)\">p</p>"
].freeze

def test_each_way_markup_can_run_code_is_reported
ACTIVE.each do |html|
refute_empty SafePostHtml.violations("<p>x</p>#{html}"), html
end
end

SAFE = [
'<img src="https://example.org/a.png" alt="그림">',
'<img src="/assets/img/a.png" srcset="/a.png 1x, /a@2x.png 2x">',
'<img src="data:image/png;base64,iVBORw0KGgo=" alt="점">',
'<a href="https://example.org/onboarding">안내</a>',
'<a href="#tldr">위로</a>',
'<a href="/posts/x/">글</a>',
'<a href="mailto:a@example.org">메일</a>',
'<a href="tel:+82-2-0000-0000">전화</a>',
'<iframe src="https://www.youtube.com/embed/x" allowfullscreen></iframe>',
'<pre><code>&lt;script&gt;alert(1)&lt;/script&gt; &lt;img onerror=x&gt;</code></pre>',
'<span data-on="x" title="based on=y">t</span>'
].freeze

def test_what_posts_carry_is_not_reported
SAFE.each do |html|
assert_empty SafePostHtml.violations(html), html
end
end
end