Escape the OpenGraph image URL in link previews

Pasting a link builds the preview by interpolating the unfurled metadata
into an HTML string. The image URL went into src="..." unescaped, so a
page whose og:image carries a double quote closes the attribute early and
everything after it becomes attributes on the preview's img element.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt
This commit is contained in:
Rosa Gutierrez
2026-09-11 16:00:07 +02:00
committed by Rosa Gutierrez
parent ef147d17db
commit c1ad057db8
4 changed files with 134 additions and 4 deletions
+6
View File
@@ -1,3 +1,5 @@
const HTML_ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }
export function truncateString(string, length, omission = "…") {
if (string.length <= length) {
return string
@@ -5,3 +7,7 @@ export function truncateString(string, length, omission = "…") {
return string.slice(0, length - omission.length) + omission
}
}
export function escapeHTML(string) {
return String(string).replace(/[&<>"']/g, character => HTML_ESCAPES[character])
}
@@ -1,5 +1,5 @@
import { post } from "@rails/request.js"
import { truncateString } from "helpers/string_helpers"
import { escapeHTML, truncateString } from "helpers/string_helpers"
const UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
const TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
@@ -68,7 +68,7 @@ export default class OpengraphEmbedOperation {
<div class="og-embed__description">${truncateString(embed.description, 560)}</div>
</div>
<div class="og-embed__image">
<img src="${embed.image}" class="image" alt="" />
<img src="${escapeHTML(embed.image)}" class="image" alt="" />
</div>
</div>
</actiontext-opengraph-embed>`
@@ -18,6 +18,18 @@ class UnfurlLinksControllerTest < ActionDispatch::IntegrationTest
assert_equal "desc..", json_response["description"]
end
test "create strips markup from the title and description" do
entity_encoded_image_tag = "&#x3c;&#x69;&#x6d;&#x67;&#x20;&#x73;&#x72;&#x63;&#x3d;&#x61;&#x20;&#x6f;&#x6e;&#x65;&#x72;&#x72;&#x6f;&#x72;&#x3d;&#x70;&#x72;&#x6f;&#x6d;&#x70;&#x74;&#x28;&#x31;&#x29;&#x3e;"
stub_successful_request title: "#{entity_encoded_image_tag}Hey!", description: "#{entity_encoded_image_tag}desc.."
post unfurl_link_url, params: { url: "https://www.example.com" }
assert_response :success
json_response = JSON.parse(response.body)
assert_equal "Hey!", json_response["title"]
assert_equal "desc..", json_response["description"]
end
test "create with missing opengraph meta tags" do
WebMock.stub_request(:get, "https://www.example.com/").to_return(status: 200, body: "<html><head></head></html>", headers: {})
@@ -49,10 +61,10 @@ class UnfurlLinksControllerTest < ActionDispatch::IntegrationTest
end
private
def stub_successful_request(url: "https://www.example.com/")
def stub_successful_request(url: "https://www.example.com/", title: "Hey!", description: "desc..")
WebMock.stub_request(:get, url).to_return(
status: 200,
body: "<html><head><meta property=\"og:url\" content=\"https://example.com\"><meta property=\"og:title\" content=\"Hey!\"><meta property=\"og:description\" content=\"desc..\"><meta property=\"og:image\" content=\"https://example.com/image.png\"></head></html>",
body: "<html><head><meta property=\"og:url\" content=\"https://example.com\"><meta property=\"og:title\" content=\"#{title}\"><meta property=\"og:description\" content=\"#{description}\"><meta property=\"og:image\" content=\"https://example.com/image.png\"></head></html>",
headers: { content_type: "text/html" }
)
+112
View File
@@ -0,0 +1,112 @@
require "application_system_test_case"
require "socket"
class UnfurlingLinksTest < ApplicationSystemTestCase
setup do
@website = Website.new
@website.start
RestrictedHTTP::PrivateNetworkGuard.stubs(:resolve).returns("127.0.0.1")
sign_in "jz@37signals.com"
join_room rooms(:designers)
end
teardown do
@website.stop
end
test "a quote in the opengraph image URL cannot add attributes to the preview" do
paste_into_composer @website.page_url
assert_selector "trix-editor .og-embed__title", text: "A normal looking link"
assert_equal @website.image_url, preview_image_attributes["src"]
assert_equal %w[ class src ], preview_image_attributes.keys.sort
end
private
def paste_into_composer(url)
page.execute_script(<<~JS, url)
const editor = document.querySelector("trix-editor")
editor.focus()
const clipboardData = new DataTransfer()
clipboardData.setData("text/plain", arguments[0])
editor.dispatchEvent(new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }))
JS
end
def preview_image_attributes
page.evaluate_script(<<~JS)
Object.fromEntries(Array.from(document.querySelector("trix-editor .og-embed__image img").attributes, attribute => [ attribute.name, attribute.value ]))
JS
end
# Serves a page whose og:image URL carries a double quote, so an unescaped
# preview closes the src attribute early and takes the rest as attributes.
class Website
def start
@socket = TCPServer.new("127.0.0.1", 0)
@thread = Thread.new { serve }
end
def stop
@thread&.kill
@socket&.close
end
def page_url
"http://127.0.0.1:#{port}/page.html"
end
def image_url
%(http://127.0.0.1:#{port}/image.png?from=" style="outline:9px solid red)
end
private
def port
@socket.addr[1]
end
def serve
loop do
client = @socket.accept
Thread.new(client) { |connection| respond_to(connection) }
end
rescue IOError, Errno::EBADF
nil
end
def respond_to(client)
request_line = client.gets.to_s
nil while (line = client.gets) && line != "\r\n"
method, path = request_line.split(" ")
if path.to_s.start_with?("/image.png")
respond client, "image/png", method == "HEAD" ? "" : "not really a PNG"
else
respond client, "text/html", page
end
rescue IOError, Errno::ECONNRESET
nil
ensure
client.close rescue nil
end
def respond(client, content_type, body)
client.write "HTTP/1.1 200 OK\r\nContent-Type: #{content_type}\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
end
def page
<<~HTML
<html><head>
<meta property="og:url" content="https://example.com/harmless">
<meta property="og:title" content="A normal looking link">
<meta property="og:description" content="Nothing to see here">
<meta property="og:image" content='#{image_url}'>
</head><body>Hello</body></html>
HTML
end
end
end