diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index f0cfa6dc0d..1fff655ef9 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -235,6 +235,12 @@ GEM tzinfo faker (3.5.2) i18n (>= 1.8.11, < 2) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.1) + net-http (>= 0.5.0) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-arm-linux-gnu) @@ -251,6 +257,7 @@ GEM globalid (1.3.0) activesupport (>= 6.1) hashdiff (1.2.1) + hashie (5.0.0) i18n (1.14.7) concurrent-ruby (~> 1.0) image_processing (1.14.0) @@ -323,6 +330,10 @@ GEM mocha (2.8.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) + multi_xml (0.7.2) + bigdecimal (~> 3.1) + net-http (0.6.0) + uri net-http-persistent (4.0.6) connection_pool (~> 2.2, >= 2.2.4) net-imap (0.5.12) @@ -354,6 +365,14 @@ GEM racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-musl) racc (~> 1.4) + oauth2 (2.0.17) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0, >= 2.0.3) + version_gem (~> 1.1, >= 1.1.9) openssl (3.3.2) ostruct (0.6.3) parallel (1.27.0) @@ -493,6 +512,9 @@ GEM sentry-ruby (6.2.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + snaky_hash (2.0.3) + hashie (>= 0.1.0, < 6) + version_gem (>= 1.1.8, < 3) sniffer (0.5.0) anyway_config (>= 1.0) dry-initializer (~> 3) @@ -548,6 +570,7 @@ GEM uri (1.1.1) vcr (6.3.1) base64 + version_gem (1.1.9) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -631,6 +654,7 @@ DEPENDENCIES mittens mocha net-http-persistent + oauth2 (~> 2.0) platform_agent prometheus-client-mmap (~> 1.3) propshaft diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index e3f8f8cbdc..7d63cbe3bb 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -34,6 +34,15 @@ } } + lexxy-editor[data-controller~="unfurl-link"] { + display: flex; + flex-direction: column; + + [data-unfurl-link-target~="linkAccountsPrompt"] { + order: 999; + } + } + .lexxy-dialog-actions { display: flex; font-size: var(--text-x-small); diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index 38c7722e2f..b8602d77b8 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -393,4 +393,19 @@ line-height: inherit; margin-block: 1em; } + + .unfurl-notice { + background-color: var(--color-selected); + border-radius: 0.5em; + font-size: var(--text-small); + padding: 0.5em 0.5em 0.5em 1em; + + p { + margin: 0; + } + + .btn { + white-space: nowrap; + } + } } diff --git a/app/controllers/unfurl_links_controller.rb b/app/controllers/unfurl_links_controller.rb new file mode 100644 index 0000000000..38554758ba --- /dev/null +++ b/app/controllers/unfurl_links_controller.rb @@ -0,0 +1,22 @@ +class UnfurlLinksController < ApplicationController + rate_limit to: 50, within: 1.hour, by: -> { Current.user.id } + + def create + link = Link.unfurl(url_param) + + if link.unfurler.requires_setup? + render \ + json: { error: :unfurler_requires_setup, config: link.unfurler.setup_config }, + status: :unprocessable_entity + elsif link.metadata + render json: link.metadata + else + head :no_content + end + end + + private + def url_param + params.require(:url) + end +end diff --git a/app/helpers/rich_text_helper.rb b/app/helpers/rich_text_helper.rb index c1bc2232a5..aacd5777b3 100644 --- a/app/helpers/rich_text_helper.rb +++ b/app/helpers/rich_text_helper.rb @@ -22,4 +22,36 @@ def code_language_picker def general_prompts(board) safe_join([ mentions_prompt(board), cards_prompt, code_language_picker ]) end + + def lexxy_rich_textarea_tag(name, value = nil, options = {}, &block) + options = options.symbolize_keys + unfurl_links = options.key?(:unfurl_links) ? options.delete(:unfurl_links) : true + + if unfurl_links + data = options[:data] ||= {} + + data[:controller] = token_list(data[:controller], "unfurl-link") + data[:unfurl_link_url_value] ||= unfurl_link_path + data[:unfurl_link_set_up_basecamp_integration_url_value] ||= new_basecamp_integration_url + data[:action] = token_list(data[:action], "lexxy:insert-link->unfurl-link#unfurl") + end + + super(name, value, options) do + concat link_unfurling_prompt if unfurl_links + concat capture(&block) if block_given? + end + end + + private + def link_unfurling_prompt + content_tag(:div, hidden: true, class: "unfurl-notice flex gap justify-space-between align-center", data: { unfurl_link_target: "linkAccountsPrompt" }) do + concat content_tag(:p, "Connect your account to get previews of Basecamp links?") + concat( + content_tag(:div, class: "flex gap-half") do + concat button_tag("Yes, connect…", class: "btn btn--link", data: { action: "unfurl-link#setUpBasecampIntegration" }) + concat button_tag("Not now", class: "btn fill-transparent", data: { action: "unfurl-link#closePrompt", unfurl_link_intent_param: "dismiss" }) + end + ) + end + end end diff --git a/app/javascript/controllers/popup_window_controller.js b/app/javascript/controllers/popup_window_controller.js new file mode 100644 index 0000000000..922ff11536 --- /dev/null +++ b/app/javascript/controllers/popup_window_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + close() { + window.close() + } +} \ No newline at end of file diff --git a/app/javascript/controllers/unfurl_link_controller.js b/app/javascript/controllers/unfurl_link_controller.js new file mode 100644 index 0000000000..5cade3b4ed --- /dev/null +++ b/app/javascript/controllers/unfurl_link_controller.js @@ -0,0 +1,109 @@ +import { Controller } from "@hotwired/stimulus"; +import { post } from "@rails/request.js" +import Cookie from "models/cookie" + +export default class extends Controller { + static targets = [ "linkAccountsPrompt" ] + static values = { + url: String, + setUpBasecampIntegrationUrl: String + } + + static MAX_DISMISSAL_COUNT = 3 + static DISMISSAL_COUNTER_KEY = "basecamp_integration_dimissal_count" + + #accountLinkingDismissed + + unfurl(event) { + this.#unfurlLink(event.detail.url, event.detail) + } + + setUpBasecampIntegration() { + this.#openPopup(this.setUpBasecampIntegrationUrlValue, { width: 400, height: 600 }) + } + + closePrompt(event) { + this.linkAccountsPromptTarget.hidden = true + + if (event.params.intent === "dismiss") { + this.#incrementDismissalCounter() + } + } + + async #unfurlLink(url, callbacks) { + const { response } = await post( + this.urlValue, + { + body: JSON.stringify({ url }), + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + } + } + ) + + let metadata = null + + if (response.status !== 204) { + metadata = await response.json() + } + + if (metadata?.error) { + this.#handleError(metadata) + } else if (metadata) { + this.#insertUnfurledLink(metadata, callbacks) + } + } + + #insertUnfurledLink(metadata, callbacks) { + callbacks.replaceLinkWith(this.#renderUnfurledLinkHTML(metadata)) + } + + #renderUnfurledLinkHTML(metadata) { + return `${metadata.title}` + } + + #handleError({ error, ...data }) { + switch (error) { + case "unfurler_requires_setup": + if (this.#shouldShowUnfurlerSetupPrompt()) { + this.#promptUnfurlerSetUp(data.config) + } + break; + default: + throw new Error(`Unknown API error: ${error}`) + break; + } + } + + #promptUnfurlerSetUp() { + this.linkAccountsPromptTarget.hidden = false + } + + #openPopup(url, options, onClose) { + const { width, height } = options + const left = (window.screen.width - width) / 2 + const top = (window.screen.height - height) / 2 + + window.open( + url, + "_blank", + `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,toolbar=no,menubar=no,location=no,status=no` + ) + } + + #incrementDismissalCounter() { + this.#cookie.increment(this.constructor.DISMISSAL_COUNTER_KEY) + this.#accountLinkingDismissed = true + } + + #shouldShowUnfurlerSetupPrompt() { + const dismissalCount = this.#cookie.get(this.constructor.DISMISSAL_COUNTER_KEY) || 0 + return !this.#accountLinkingDismissed && (dismissalCount < this.constructor.MAX_DISMISSAL_COUNT) + } + + get #cookie() { + const name = `link-unfurler-setup-prompt` + return Cookie.find(name) || new Cookie(name) + } +} diff --git a/app/javascript/models/cookie.js b/app/javascript/models/cookie.js new file mode 100644 index 0000000000..2e2d6b7e78 --- /dev/null +++ b/app/javascript/models/cookie.js @@ -0,0 +1,58 @@ +export default class Cookie { + static DEFAULT_EXPIRATION = 20 * 365 * 24 * 60 * 60 * 1000 + + static find(name) { + const value = document.cookie + .split("; ") + .find(row => row.startsWith(`${name}=`)) + ?.split("=")[1] + + if (!value) return null + + try { + const data = JSON.parse(decodeURIComponent(value)) + return new Cookie(name, data) + } catch { + return new Cookie(name, { value: decodeURIComponent(value) }) + } + } + + constructor(name, data = {}, options = {}) { + this.name = name + this.data = data + this.options = options + } + + get(key) { + return this.data[key] + } + + set(key, value) { + this.data[key] = value + this.save() + } + + increment(key, amount = 1) { + const currentValue = this.data[key] || 0 + + try { + this.set(key, currentValue + amount) + } catch { + this.set(key, amount) + } + } + + save() { + const value = encodeURIComponent(JSON.stringify(this.data)) + const defaultExpires = new Date(Date.now() + this.constructor.DEFAULT_EXPIRATION) + const expires = `; expires=${(this.options.expires || defaultExpires).toUTCString()}` + const path = `; path=${this.options.path || "/"}` + const sameSite = this.options.sameSite ? `; SameSite=${this.options.sameSite}` : "; SameSite=Lax" + + document.cookie = `${this.name}=${value}${expires}${path}${sameSite}` + } + + delete() { + document.cookie = `${this.name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/` + } +} diff --git a/app/jobs/integration/basecamp/set_up_job.rb b/app/jobs/integration/basecamp/set_up_job.rb new file mode 100644 index 0000000000..96de96c6c3 --- /dev/null +++ b/app/jobs/integration/basecamp/set_up_job.rb @@ -0,0 +1,5 @@ +class Integration::Basecamp::SetUpJob < ApplicationJob + def perform(code:, state:) + Integration::Basecamp.set_up(code: code, state: state) + end +end diff --git a/app/models/integration.rb b/app/models/integration.rb new file mode 100644 index 0000000000..1286c6db66 --- /dev/null +++ b/app/models/integration.rb @@ -0,0 +1,5 @@ +class Integration < ApplicationRecord + belongs_to :owner, class_name: "User" + + store :data, coder: JSON +end diff --git a/app/models/link.rb b/app/models/link.rb new file mode 100644 index 0000000000..eb6b5b0601 --- /dev/null +++ b/app/models/link.rb @@ -0,0 +1,32 @@ +class Link + UNFURLERS = [ + FizzyUnfurler, + ] + + attr_reader :uri, :metadata, :user + + def self.unfurl(url, **options) + new(url).unfurl(**options) + end + + def initialize(url, user: Current.user) + @uri = URI.parse(url) + @metadata = nil + @user = user + end + + def unfurl + if unfurler&.setup? + @metadata = unfurler.unfurl + end + + self + end + + def unfurler + @unfurler ||= begin + unfurler = UNFURLERS.find { |unfurler| unfurler.unfurls?(uri) } + unfurler&.new(uri, user: user) + end + end +end diff --git a/app/models/link/fetch.rb b/app/models/link/fetch.rb new file mode 100644 index 0000000000..5992675cf5 --- /dev/null +++ b/app/models/link/fetch.rb @@ -0,0 +1,118 @@ +class Link::Fetch + class Error < StandardError; end + + class TooManyRedirectsError < Error; end + + class RedirectDeniedError < Error; end + + class BodyTooLargeError < Error; end + + class UnsuccesfulRequestError < Error + attr_reader :response + + def initialize(response) + @response = response + super("HTTP response code: #{response.code}") + end + end + + DEFAULT_USER_AGENT = "Mozilla/5.0 (compatible; FizzyLinkUnfurler/1.0.0)".freeze + MAX_BODY_SIZE = 2.megabytes + MAX_REDIRECTS = 10 + DNS_RESOLUTION_TIMEOUT = 2.seconds + + attr_reader :uri, :headers, :max_body_size, :dns_resolution_timeout + + def initialize(url, headers: {}, max_body_size: MAX_BODY_SIZE, dns_resolution_timeout: DNS_RESOLUTION_TIMEOUT) + @uri = URI.parse(url) + @headers = default_headers.merge(headers) + @max_body_size = max_body_size + @dns_resolution_timeout = dns_resolution_timeout + end + + def http_url? + uri.is_a?(URI::HTTP) + end + + def html_content? + content_type&.starts_with? "text/html" + end + + def content_type + request uri, Net::HTTP::Head do |response| + if response.is_a?(Net::HTTPSuccess) + return response["Content-Type"] + else + raise UnsuccesfulRequestError, response + end + end + end + + def content + request uri, Net::HTTP::Get do |response| + if response.is_a?(Net::HTTPSuccess) + body_size = 0 + buffer = StringIO.new + + response.read_body do |chunk| + body_size += chunk.bytesize + + if body_size <= max_body_size + buffer << chunk + else + raise BodyTooLargeError + end + end + + return buffer.string + else + raise UnsuccesfulRequestError, response + end + end + end + + private + def request(uri, request_class, ip_address: nil) + ip_address ||= resolve_ip_address(uri.host) + + MAX_REDIRECTS.times do + Net::HTTP.start(uri.host, uri.port, ipaddr: ip_address, use_ssl: uri.scheme == "https") do |http| + request = request_class.new(uri) + + headers.each do |header, value| + request[header] = value + end + + http.request(request) do |response| + if response.is_a?(Net::HTTPRedirection) + uri, ip_address = resolve_redirect(response["location"]) + else + yield response + end + end + end + end + + raise TooManyRedirectsError + end + + def default_headers + { + "Accept" => "text/html,application/xhtml+xml", + "User-Agent" => DEFAULT_USER_AGENT + } + end + + def resolve_redirect(location) + uri = URI.parse(location) + raise RedirectDeniedError unless uri.is_a?(URI::HTTP) + + [ uri, resolve_ip_address(uri.host) ] + rescue NetworkGuard::RestrictedHostError + raise RedirectDeniedError + end + + def resolve_ip_address(hostname) + NetworkGuard.resolve(hostname, timeout: dns_resolution_timeout).sample + end +end diff --git a/app/models/link/fizzy_unfurler.rb b/app/models/link/fizzy_unfurler.rb new file mode 100644 index 0000000000..cf09dbe287 --- /dev/null +++ b/app/models/link/fizzy_unfurler.rb @@ -0,0 +1,65 @@ +class Link::FizzyUnfurler + class << self + def unfurls?(uri) + uri.host == fizzy_host && uri.port == fizzy_port + rescue URI::InvalidURIError + false + end + + private + def fizzy_host + url_options[:host] + end + + def fizzy_port + url_options[:port] + end + + def url_options + Rails.application.config.action_mailer.default_url_options + end + end + + attr_reader :uri, :user + + def initialize(uri, user:, **) + @uri = uri + @user = user + end + + def requires_setup? + false + end + + def setup_config + nil + end + + def unfurl + tenant, path = extract_tenant_from_path(uri.path) + + if tenant == ApplicationRecord.current_tenant + target = Rails.application.routes.recognize_path(path) rescue {} + + case target + in { controller: "cards", action: "show", id: id } then unfurl_card(id) + else nil + end + end + end + + private + def extract_tenant_from_path(path) + parts = path.match(%r{\A/(?\d+)(?.+)\Z}) + + [ parts[:tenant], parts[:path] ] + end + + def unfurl_card(id) + card = user.accessible_cards.find_by(id: id) + + if card + Link::Metadata.new(title: card.title, canonical_url: uri.to_s) + end + end +end diff --git a/app/models/link/metadata.rb b/app/models/link/metadata.rb new file mode 100644 index 0000000000..61ffa8f604 --- /dev/null +++ b/app/models/link/metadata.rb @@ -0,0 +1,49 @@ +class Link::Metadata + include ActionView::Helpers::SanitizeHelper + + attr_reader :title, :description, :image_url, :canonical_url, + :unsafe_title, :unsafe_description, :unsafe_image_url, :unsafe_canonical_url + + def initialize(**attributes) + @unsafe_canonical_url = attributes[:canonical_url] + @canonical_url = sanitize_url(@unsafe_canonical_url) + + @unsafe_title = attributes[:title] + @title = sanitize_text(@unsafe_title) + + @unsafe_description = attributes[:description] + @description = sanitize_text(@unsafe_description) + + @unsafe_image_url = attributes[:image_url] + @image_url = sanitize_url(absolute_uri(@unsafe_image_url, relative_to: @canonical_url)) + end + + private + def sanitize_text(content) + sanitize(strip_tags(content)) + end + + def sanitize_url(url, relative_to: nil) + uri = URI.parse(url) + + if uri.is_a?(URI::HTTP) && uri.absolute? + uri.to_s + else + nil + end + rescue URI::InvalidURIError + nil + end + + def absolute_uri(url, relative_to:) + uri = URI.parse(url) + + if uri.absolute? + uri + else + URI.parse(relative_to) + uri + end + rescue URI::InvalidURIError + nil + end +end diff --git a/app/models/link/open_graph_unfurler.rb b/app/models/link/open_graph_unfurler.rb new file mode 100644 index 0000000000..9a4e05c3f6 --- /dev/null +++ b/app/models/link/open_graph_unfurler.rb @@ -0,0 +1,62 @@ +class Link::OpenGraphUnfurler + attr_reader :uri, :user + + def self.unfurls?(uri) + uri.is_a?(URI::HTTP) + end + + def initialize(uri, user: nil, **options) + @uri = uri + @user = user + end + + def requires_setup? + false + end + + def setup_config + nil + end + + def unfurl + fetch = Link::Fetch.new(uri) + + if fetch.http_url? && fetch.html_content? + content = fetch.content + document = Nokogiri::HTML5(content) + Link::Metadata.new(**extract_metadata_from_document(document)) + end + end + + private + def extract_metadata_from_document(document) + Hash.new.tap do |metadata| + metadata[:canonical_url] = extract_canonical_url_from_document(document) || uri.to_s + metadata[:title] = extract_title_from_document(document) + metadata[:description] = extract_description_from_document(document) + metadata[:image_url] = extract_image_url_from_document(document) + end + end + + def extract_canonical_url_from_document(document) + document.at_css('meta[property="og:url"]')&.get_attribute("content") || + document.at_css('link[rel="canonical"]')&.get_attribute("href") + end + + def extract_title_from_document(document) + document.at_css('meta[property="og:title"]')&.get_attribute("content") || + document.at_css('meta[name="twitter:title"]')&.get_attribute("content") || + document.at_css("title")&.text&.strip + end + + def extract_description_from_document(document) + document.at_css('meta[property="og:description"]')&.get_attribute("content") || + document.at_css('meta[name="twitter:description"]')&.get_attribute("content") || + document.at_css('meta[name="description"]')&.get_attribute("content") + end + + def extract_image_url_from_document(document) + document.at_css('meta[property="og:image"]')&.get_attribute("content") || + document.at_css('meta[name="twitter:image"]')&.get_attribute("content") + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 68df97a65e..4e7bea851c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -17,6 +17,7 @@ class User < ApplicationRecord has_many :pins, dependent: :destroy has_many :pinned_cards, through: :pins, source: :card has_many :exports, class_name: "Account::Export", dependent: :destroy + has_many :integrations, foreign_key: :owner_id, inverse_of: :owner, dependent: :destroy scope :with_avatars, -> { preload(:account, :avatar_attachment) } diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index ce69c01add..79fcd0f0f2 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -54,8 +54,8 @@ def succeeded? private def perform_request - if private_uri? - { error: :private_uri } + if restricted_uri? + { error: :restricted_uri } else response = http.request( Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload } @@ -73,18 +73,8 @@ def perform_request { error: :failed_tls } end - def private_uri? - ip_addresses = [] - - Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns| - dns.each_address(uri.host) do |ip_address| - ip_addresses << IPAddr.new(ip_address) - end - end - - ip_addresses.any? do |ip| - ip.private? || ip.loopback? || ip.link_local? || ip.ipv4_mapped? || DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) } - end + def restricted_uri? + NetworkGuard.restricted_host?(uri.host, timeout: DNS_RESOLUTION_TIMEOUT) end def uri diff --git a/app/views/integrations/basecamps/callbacks/show.html.erb b/app/views/integrations/basecamps/callbacks/show.html.erb new file mode 100644 index 0000000000..b143db0d60 --- /dev/null +++ b/app/views/integrations/basecamps/callbacks/show.html.erb @@ -0,0 +1,8 @@ +<% @hide_footer_frames = true %> + +
+ Your accounts are now linked +

From now on, any Basecamp link you paste into Fizzy will automatically be turned into a preview.

+ + +
diff --git a/app/views/integrations/basecamps/create.html.erb b/app/views/integrations/basecamps/create.html.erb new file mode 100644 index 0000000000..643afdcb6c --- /dev/null +++ b/app/views/integrations/basecamps/create.html.erb @@ -0,0 +1,8 @@ +<% @hide_footer_frames = true %> + +
+ Your accounts are already linked +

There's nothing left to do. Any Basecamp link you paste into Fizzy will automatically be turned into a preview

+ + +
diff --git a/app/views/integrations/basecamps/new.html.erb b/app/views/integrations/basecamps/new.html.erb new file mode 100644 index 0000000000..a6005171ef --- /dev/null +++ b/app/views/integrations/basecamps/new.html.erb @@ -0,0 +1,11 @@ +<% @hide_footer_frames = true %> + +
+ Connect to Basecamp to auto-preview links? +

If you want links from Basecamp to use the title of the item "important message" rather than just the URL "http://basecamp.com/1234", connect your Basecamp account to Fizzy.

+

In the next step you'll sign in to Basecamp and authorize the connection to Fizzy.

+
+ <%= button_to "Yes, continue…", basecamp_integration_path, method: :post, data: { turbo: false }, class: "btn btn--link" %> + +
+
diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 6c99b817b6..0209c3033a 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -46,6 +46,29 @@ ], "note": "" }, + { + "warning_type": "Redirect", + "warning_code": 18, + "fingerprint": "a8fc3f4864b089c35f52d7d5607a60f70aedb99ba46b9b0f2ca41ddc8386a03c", + "check_name": "Redirect", + "message": "Possible unprotected redirect", + "file": "app/controllers/integrations/basecamps_controller.rb", + "line": 9, + "link": "https://brakemanscanner.org/docs/warning_types/redirect/", + "code": "redirect_to(Integration::Basecamp.find_or_create_by(:owner => Current.user).authorization_url, :allow_other_host => true)", + "render_path": null, + "location": { + "type": "method", + "class": "Integrations::BasecampsController", + "method": "create" + }, + "user_input": "Integration::Basecamp.find_or_create_by(:owner => Current.user).authorization_url", + "confidence": "Weak", + "cwe_id": [ + 601 + ], + "note": "" + }, { "warning_type": "Mass Assignment", "warning_code": 70, diff --git a/config/importmap.rb b/config/importmap.rb index 6988153410..b8179b189c 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -9,6 +9,7 @@ pin_all_from "app/javascript/controllers", under: "controllers" pin_all_from "app/javascript/helpers", under: "helpers" pin_all_from "app/javascript/initializers", under: "initializers" +pin_all_from "app/javascript/models", under: "models" pin "marked" # @15.0.11 pin "lexxy" pin "@rails/activestorage", to: "activestorage.esm.js" diff --git a/config/routes.rb b/config/routes.rb index bbce4fe01d..78232d3ba5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -186,6 +186,8 @@ end end + resource :unfurl_link, only: :create + namespace :public do resources :boards do scope module: :boards do diff --git a/db/migrate/20251202144419_create_integrations.rb b/db/migrate/20251202144419_create_integrations.rb new file mode 100644 index 0000000000..9eed74e091 --- /dev/null +++ b/db/migrate/20251202144419_create_integrations.rb @@ -0,0 +1,11 @@ +class CreateIntegrations < ActiveRecord::Migration[8.1] + def change + create_table :integrations do |t| + t.text :data + t.string :type + t.belongs_to :owner, null: false, foreign_key: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b84d3c1603..8a9bf2b102 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do +ActiveRecord::Schema[8.2].define(version: 2025_12_02_144419) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -321,6 +321,15 @@ t.index ["email_address"], name: "index_identities_on_email_address", unique: true end + create_table "integrations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "data" + t.bigint "owner_id", null: false + t.string "type" + t.datetime "updated_at", null: false + t.index ["owner_id"], name: "index_integrations_on_owner_id" + end + create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "code", null: false t.datetime "created_at", null: false diff --git a/lib/network_guard.rb b/lib/network_guard.rb new file mode 100644 index 0000000000..ccb6ecc56c --- /dev/null +++ b/lib/network_guard.rb @@ -0,0 +1,40 @@ +module NetworkGuard + class RestrictedHostError < StandardError; end + + extend self + + RESTRICTED_IP_RANGES = [ + # IPv4 mapped to IPv6 + IPAddr.new("::ffff:0:0/96"), + # Broadcasts + IPAddr.new("0.0.0.0/8") + ].freeze + + def restricted_host?(hostname, **options) + resolve(hostname, **options).any? + rescue RestrictedHostError + false + end + + def resolve(hostname, timeout: nil) + ip_addresses = [] + + Resolv::DNS.open(timeouts: timeout) do |dns| + dns.each_address(hostname) do |ip_address| + ip_addresses << IPAddr.new(ip_address) + end + end + + if ip_addresses.any? { |ip_address| restricted_ip_address?(ip_address) } + raise RestrictedHostError + else + ip_addresses + end + end + + def restricted_ip_address?(ip_address) + ip_address.private? || + ip_address.loopback? || + DISALLOWED_IP_RANGES.any? { |range| range.include?(ip_address) } + end +end diff --git a/test/fixtures/integrations.yml b/test/fixtures/integrations.yml new file mode 100644 index 0000000000..deaa98d2b3 --- /dev/null +++ b/test/fixtures/integrations.yml @@ -0,0 +1,7 @@ +kevins_basecamp: + type: "Integration::Basecamp" + owner: kevin + data: '<%= { + access_token: "BAhbB0kiAjoBeyJjbGllbnRfaWQiOiI5ZDJmZjU2NDM4YTg1MTNjMjAwNDQ3NTJkMWYzNjE2ZTA3NmUwYTU3IiwiZXhwaXJlc19hdCI6IjIwMjUtMTAtMDZUMTM6MjM6NDlaIiwidXNlcl9pZHMiOlsyNjIxOTgyNTAsMTI2NTEwMDgzLDk0OTc3OTE2NSw5NTA3NTg3OSw4NjgyMTE0NDEsNzA3NTQwNDE1LDIwOTUwMDc0Miw2NDUzOTU2OTAsODc5OTczNzU2LDk3NjAyNDY4LDkwMDk4Nzc1OSwxMTg0NzE1MDQsMjQ5MTUxMjgzLDQwMDE1OTMwNCw4MDk5MDk3Nl0sInZlcnNpb24iOjEsImFwaV9kZWFkYm9sdCI6ImRjOTUzYmYwMDdmMTQyNGY0YWYyN2FjMzI0NGYyZjYzIn0GOgZFVEl1OglUaW1lDc1kH8CpNxZfCToNbmFub19udW1pAjICOg1uYW5vX2RlbmkGOg1zdWJtaWNybyIHViA6CXpvbmVJIghVVEMGOwBG--56891137f381109fc1c075ca95d871b529040fba", + refresh_token: "BAhbB0kiAjoBeyJjbGllbnRfaWQiOiI5ZDJmZjU2NDM4YTg1MTNjMjAwNDQ3NTJkMWYzNjE2ZTA3NmUwYTU3IiwiZXhwaXJlc19hdCI6IjIwMzUtMDktMjJUMTM6MjM6NDlaIiwidXNlcl9pZHMiOlsyNjIxOTgyNTAsMTI2NTEwMDgzLDk0OTc3OTE2NSw5NTA3NTg3OSw4NjgyMTE0NDEsNzA3NTQwNDE1LDIwOTUwMDc0Miw2NDUzOTU2OTAsODc5OTczNzU2LDk3NjAyNDY4LDkwMDk4Nzc1OSwxMTg0NzE1MDQsMjQ5MTUxMjgzLDQwMDE1OTMwNCw4MDk5MDk3Nl0sInZlcnNpb24iOjEsImFwaV9kZWFkYm9sdCI6ImRjOTUzYmYwMDdmMTQyNGY0YWYyN2FjMzI0NGYyZjYzIn0GOgZFVEl1OglUaW1lDc3iIcAlWBZfCToNbmFub19udW1pAX46DW5hbm9fZGVuaQY6DXN1Ym1pY3JvIgcSYDoJem9uZUkiCFVUQwY7AEY=--c94d9dee4fe6d86c5f30cc1b6b2352c142676557" + }.to_json %>' diff --git a/test/models/link/fetch_test.rb b/test/models/link/fetch_test.rb new file mode 100644 index 0000000000..acf6ee84a6 --- /dev/null +++ b/test/models/link/fetch_test.rb @@ -0,0 +1,69 @@ +require "test_helper" + +class Link::FetchTest < ActiveSupport::TestCase + test "http_url?" do + fetch = Link::Fetch.new("https://example.com/page") + assert fetch.http_url? + + non_http_fetch = Link::Fetch.new("ftp://example.com/file") + assert_not non_http_fetch.http_url? + end + + test "html_content?" do + fetch = Link::Fetch.new("https://example.com/page") + + stub_request(:head, "https://example.com/page") + .to_return(status: 200, headers: { "Content-Type" => "text/html; charset=utf-8" }) + + assert fetch.html_content? + + stub_request(:head, "https://example.com/image") + .to_return(status: 200, headers: { "Content-Type" => "image/jpeg" }) + + image_fetch = Link::Fetch.new("https://example.com/image") + assert_not image_fetch.html_content? + end + + test "content_type" do + fetch = Link::Fetch.new("https://example.com/page") + + stub_request(:head, "https://example.com/page") + .to_return(status: 200, headers: { "Content-Type" => "text/html; charset=utf-8" }) + + assert_equal "text/html; charset=utf-8", fetch.content_type + + stub_request(:head, "https://example.com/error") + .to_return(status: 404) + + error_fetch = Link::Fetch.new("https://example.com/error") + assert_raises(Link::Fetch::UnsuccesfulRequestError) do + error_fetch.content_type + end + end + + test "content" do + fetch = Link::Fetch.new("https://example.com/page") + + stub_request(:get, "https://example.com/page") + .to_return(status: 200, body: "Test") + + content = fetch.content + assert_includes content, "Test" + + stub_request(:get, "https://example.com/large") + .to_return(status: 200, body: "x" * (Link::Fetch::MAX_BODY_SIZE + 1)) + + large_fetch = Link::Fetch.new("https://example.com/large") + assert_raises(Link::Fetch::BodyTooLargeError) do + large_fetch.content + end + + stub_request(:get, "https://example.com/error") + .to_return(status: 404) + + error_fetch = Link::Fetch.new("https://example.com/error") + assert_raises(Link::Fetch::UnsuccesfulRequestError) do + error_fetch.content + end + end +end diff --git a/test/models/link/fizzy_unfurler_test.rb b/test/models/link/fizzy_unfurler_test.rb new file mode 100644 index 0000000000..ed39370f45 --- /dev/null +++ b/test/models/link/fizzy_unfurler_test.rb @@ -0,0 +1,40 @@ +require "test_helper" + +class Link::FizzyUnfurlerTest < ActiveSupport::TestCase + setup do + @original_url_options = Rails.application.config.action_mailer.default_url_options + Rails.application.config.action_mailer.default_url_options = { host: "fizzy.example.com", port: 3000 } + end + + teardown do + Rails.application.config.action_mailer.default_url_options = @original_url_options + end + + test "unfurls?" do + assert Link::FizzyUnfurler.unfurls?(URI.parse("https://fizzy.example.com:3000/123/cards/456")) + assert Link::FizzyUnfurler.unfurls?(URI.parse("http://fizzy.example.com:3000/123/any/path")) + + assert_not Link::FizzyUnfurler.unfurls?(URI.parse("https://other.example.com:3000/123/cards/456")) + assert_not Link::FizzyUnfurler.unfurls?(URI.parse("https://fizzy.example.com:3001/123/cards/456")) + end + + test "unfurl" do + user = users(:david) + card = cards(:logo) + tenant_id = ApplicationRecord.current_tenant + url = "https://fizzy.example.com:3000/#{tenant_id}/cards/#{card.id}" + + metadata = Link::FizzyUnfurler.new(URI.parse(url), user: user).unfurl + + assert_equal card.title, metadata.title + assert_equal url, metadata.canonical_url + + # Test different tenant + different_tenant_url = "https://fizzy.example.com:3000/999/cards/#{card.id}" + assert_nil Link::FizzyUnfurler.new(URI.parse(different_tenant_url), user: user).unfurl + + # Test non-existent card + non_existent_url = "https://fizzy.example.com:3000/#{tenant_id}/cards/99999" + assert_nil Link::FizzyUnfurler.new(URI.parse(non_existent_url), user: user).unfurl + end +end diff --git a/test/models/link/metadata_test.rb b/test/models/link/metadata_test.rb new file mode 100644 index 0000000000..96e9fc12d6 --- /dev/null +++ b/test/models/link/metadata_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class Link::MetadataTest < ActiveSupport::TestCase + test "initialize" do + metadata = Link::Metadata.new( + title: "Safe TitleBold", + description: "

Paragraph with link

", + canonical_url: "https://example.com/page", + image_url: "/images/photo.jpg" + ) + + assert_equal "alert('xss')Safe TitleBold", metadata.title + assert_equal "Paragraph with link", metadata.description + assert_equal "https://example.com/page", metadata.canonical_url + assert_equal "https://example.com/images/photo.jpg", metadata.image_url + + assert_equal "Safe TitleBold", metadata.unsafe_title + assert_equal "

Paragraph with link

", metadata.unsafe_description + assert_equal "https://example.com/page", metadata.unsafe_canonical_url + assert_equal "/images/photo.jpg", metadata.unsafe_image_url + + invalid_metadata = Link::Metadata.new( + canonical_url: "javascript:alert('xss')", + image_url: "ftp://example.com/image.jpg" + ) + + assert_nil invalid_metadata.canonical_url + assert_nil invalid_metadata.image_url + end +end diff --git a/test/models/link/open_graph_unfurler_test.rb b/test/models/link/open_graph_unfurler_test.rb new file mode 100644 index 0000000000..a118c549ed --- /dev/null +++ b/test/models/link/open_graph_unfurler_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class Link::OpenGraphUnfurlerTest < ActiveSupport::TestCase + test "unfurls?" do + assert Link::OpenGraphUnfurler.unfurls?(URI.parse("https://example.com/page")) + assert Link::OpenGraphUnfurler.unfurls?(URI.parse("https://any-site.com/path")) + assert_not Link::OpenGraphUnfurler.unfurls?(URI.parse("ftp://any-site.com/path")) + end + + test "unfurl" do + url = "https://example.com/page" + + stub_request(:head, url) + .to_return(status: 200, headers: { "Content-Type" => "text/html" }) + + stub_request(:get, url) + .to_return(status: 200, body: <<~HTML) + + + Page Title + + + + + + + HTML + + metadata = Link::OpenGraphUnfurler.new(URI.parse(url)).unfurl + + assert_equal "OG Title", metadata.title + assert_equal "OG Description", metadata.description + assert_equal "https://example.com/canonical", metadata.canonical_url + assert_equal "https://example.com/image.jpg", metadata.image_url + + stub_request(:head, "https://example.com/non-html") + .to_return(status: 200, headers: { "Content-Type" => "application/json" }) + + assert_nil Link::OpenGraphUnfurler.new(URI.parse("https://example.com/non-html")).unfurl + assert_nil Link::OpenGraphUnfurler.new(URI.parse("ftp://example.com/file")).unfurl + end +end diff --git a/test/models/link_test.rb b/test/models/link_test.rb new file mode 100644 index 0000000000..7027ffebcb --- /dev/null +++ b/test/models/link_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class LinkTest < ActiveSupport::TestCase + include VcrTestHelper + + test "unfurl" do + user = users(:kevin) + link = Link.new("http://3.basecamp.localhost:3001/181900405/buckets/1042979247/messages/783526101") + + assert_changes -> { link.metadata }, from: nil do + link.unfurl(user: user) + end + + assert_kind_of Link::Metadata, link.metadata + end +end