mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-09-13 03:52:05 +09:00
Render link previews only from web URLs
A link preview's link and image come from attributes on the message body, which the composer fills in from the unfurl the server performed. A body written by hand can put anything in those attributes, and the preview partial rendered them as they were. Keep the link and the image only when they parse as absolute http or https URLs, so nothing in a message body can aim either one at another scheme or at a path on this Campfire, and render the title and the description as text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186eyzivcTn6wqjEE4Wnxdt
This commit is contained in:
@@ -3,9 +3,9 @@
|
||||
<div class="og-embed gap">
|
||||
<div class="og-embed__content">
|
||||
<div class="og-embed__title">
|
||||
<%= link_to truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
|
||||
<%= link_to_if opengraph_embed.href.present?, truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
|
||||
</div>
|
||||
<div class="og-embed__description"><%= truncate(opengraph_embed.caption, length: 560, omission: "…").html_safe %></div>
|
||||
<div class="og-embed__description"><%= truncate(opengraph_embed.caption, length: 560, omission: "…") %></div>
|
||||
</div>
|
||||
<% if opengraph_embed.url %>
|
||||
<div class="og-embed__image">
|
||||
|
||||
@@ -16,12 +16,22 @@ class ActionText::Attachment::OpengraphEmbed
|
||||
private
|
||||
def attributes_from_node(node)
|
||||
{
|
||||
href: node["href"],
|
||||
url: node["url"],
|
||||
href: web_url(node["href"]),
|
||||
url: web_url(node["url"]),
|
||||
filename: node["filename"],
|
||||
description: node["caption"]
|
||||
}
|
||||
end
|
||||
|
||||
# A link preview points at what we unfurled, which is always an absolute
|
||||
# http or https URL. Drop anything else the message body asks for, so a
|
||||
# body written by hand can't aim the preview's link or its image at another
|
||||
# scheme or at a path on this Campfire.
|
||||
def web_url(value)
|
||||
value if value.present? && URI.parse(value).is_a?(URI::HTTP)
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
attr_accessor :href, :url, :filename, :description
|
||||
|
||||
@@ -20,6 +20,35 @@ class RoomsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert response.cookies[:last_room] = users(:david).rooms.last.id
|
||||
end
|
||||
|
||||
test "show renders a link preview written by hand without its off-scheme image and link" do
|
||||
room = rooms(:watercooler)
|
||||
post room_messages_url(room, format: :turbo_stream), params: { message: {
|
||||
body: link_preview_body(href: "javascript:alert(1)", url: "data:image/svg+xml;base64,PHN2Zy8+"),
|
||||
client_message_id: "hand-written-preview" } }
|
||||
assert_response :success
|
||||
|
||||
get room_url(room)
|
||||
|
||||
assert_response :success
|
||||
assert_no_match /javascript:alert/, response.body
|
||||
assert_no_match /data:image\/svg/, response.body
|
||||
assert_match "Free cookies", response.body
|
||||
end
|
||||
|
||||
test "show renders an unfurled link preview" do
|
||||
room = rooms(:watercooler)
|
||||
post room_messages_url(room, format: :turbo_stream), params: { message: {
|
||||
body: link_preview_body(href: "https://example.com/page", url: "https://example.com/image.png"),
|
||||
client_message_id: "unfurled-preview" } }
|
||||
assert_response :success
|
||||
|
||||
get room_url(room)
|
||||
|
||||
assert_response :success
|
||||
assert_match %r{<img src="https://example\.com/image\.png"}, response.body
|
||||
assert_match %r{href="https://example\.com/page"}, response.body
|
||||
end
|
||||
|
||||
test "destroy" do
|
||||
assert_turbo_stream_broadcasts :rooms, count: 1 do
|
||||
assert_difference -> { Room.count }, -1 do
|
||||
@@ -42,4 +71,10 @@ class RoomsControllerTest < ActionDispatch::IntegrationTest
|
||||
delete room_url(rooms(:designers))
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def link_preview_body(href:, url:)
|
||||
%(<div><action-text-attachment content-type="application/vnd.actiontext.opengraph-embed" ) +
|
||||
%(href="#{href}" url="#{url}" filename="Free cookies" caption="Cookies here"></action-text-attachment></div>)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionText::Attachment::OpengraphEmbedTest < ActiveSupport::TestCase
|
||||
test "keeps absolute http and https links and images" do
|
||||
embed = embed_from href: "http://example.com/page", url: "https://example.com/image.png"
|
||||
|
||||
assert_equal "http://example.com/page", embed.href
|
||||
assert_equal "https://example.com/image.png", embed.url
|
||||
end
|
||||
|
||||
test "drops a link and an image that aren't web URLs" do
|
||||
[ "javascript:alert(1)", "data:text/html,pwned", "vbscript:msgbox(1)", "//example.com/image.png",
|
||||
"/rooms/1", "rooms/1", "", "http://exa mple.com/ " ].each do |value|
|
||||
embed = embed_from href: value, url: value
|
||||
|
||||
assert_nil embed.href, "expected #{value.inspect} to be dropped as a link"
|
||||
assert_nil embed.url, "expected #{value.inspect} to be dropped as an image"
|
||||
end
|
||||
end
|
||||
|
||||
test "renders the image and the link when both are web URLs" do
|
||||
html = render_embed href: "https://example.com/page", url: "https://example.com/image.png"
|
||||
|
||||
assert_match %r{<a rel="noreferrer" target="_blank" href="https://example\.com/page">Title</a>}, html
|
||||
assert_match %r{<img src="https://example\.com/image\.png"}, html
|
||||
end
|
||||
|
||||
test "renders no image and no link when neither is a web URL" do
|
||||
html = render_embed href: "javascript:alert(1)", url: "data:image/svg+xml;base64,PHN2Zy8+"
|
||||
|
||||
assert_no_match /javascript:/, html
|
||||
assert_no_match /data:/, html
|
||||
assert_no_match /<img/, html
|
||||
assert_no_match /<a /, html
|
||||
assert_match "Title", html
|
||||
end
|
||||
|
||||
test "renders the title and the description as text" do
|
||||
html = render_embed href: "https://example.com/page", url: "https://example.com/image.png",
|
||||
filename: "<b>Title</b>", caption: "<img src=x onerror=alert(1)>"
|
||||
|
||||
assert_no_match /<b>/, html
|
||||
assert_no_match /<img src=x/, html
|
||||
assert_match "<b>Title</b>", html
|
||||
assert_match "<img src=x onerror=alert(1)>", html
|
||||
end
|
||||
|
||||
private
|
||||
def attachment_for(href:, url:, filename: "Title", caption: "Description")
|
||||
html = %(<action-text-attachment content-type="application/vnd.actiontext.opengraph-embed" ) +
|
||||
%(href="#{href}" url="#{url}" filename="#{filename}" caption="#{caption}"></action-text-attachment>)
|
||||
node = ActionText::Fragment.wrap(html).find_all(ActionText::Attachment.tag_name).first
|
||||
|
||||
ActionText::Attachment.from_node(node)
|
||||
end
|
||||
|
||||
def embed_from(**attributes)
|
||||
attachment_for(**attributes).attachable
|
||||
end
|
||||
|
||||
def render_embed(**attributes)
|
||||
attachment = attachment_for(**attributes)
|
||||
|
||||
ApplicationController.render partial: attachment.to_partial_path, locals: { opengraph_embed: attachment }
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user