discourse/plugins/discourse-ai/lib/agents/tool_runner/upload.rb
Alan Guo Xiang Tan eeaca70bc1
FIX: Enforce secure-upload ACL in AI bot prompt path (#39903)
The AI bot reads upload contents from posts and chat messages and feeds
them into the LLM prompt. The lookup is gated by whether the requester
can see the post, but not whether they can see the upload's secure
access-control post, so an attacker can paste another user's secure
short URL into their own post, summon the bot, and have it disclose the
contents. The agent tool runner's `_upload_get_base64` has the same gap
with no ACL check at all.

This commit introduces `Guardian#can_see_upload?` so upload visibility
is checked in one place, and uses it from `PromptMessagesBuilder`,
`ToolRunner::Upload`, and
`SecureUploadEndpointHelpers#check_secure_upload_permission`.

Follow-up to fa54f62348.
2026-05-13 09:55:32 +08:00

103 lines
3.4 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscourseAi
module Agents
class ToolRunner
module Upload
def attach_upload(mini_racer_context)
mini_racer_context.attach(
"_upload_get_base64",
->(upload_id_or_url, max_pixels) do
in_attached_function do
return nil if upload_id_or_url.blank?
upload = nil
# Handle both upload ID and short URL
if upload_id_or_url.to_s.start_with?("upload://")
# Handle short URL format
sha1 = ::Upload.sha1_from_short_url(upload_id_or_url)
return nil if sha1.blank?
upload = ::Upload.find_by(sha1: sha1)
else
# Handle numeric ID
upload_id = upload_id_or_url.to_i
return nil if upload_id <= 0
upload = ::Upload.find_by(id: upload_id)
end
return nil if upload.nil?
guardian = @context.user&.guardian || Guardian.new
return nil if !guardian.can_see_upload?(upload)
max_pixels = max_pixels&.to_i
max_pixels = nil if max_pixels && max_pixels <= 0
encoded_uploads =
DiscourseAi::Completions::UploadEncoder.encode(
upload_ids: [upload.id],
max_pixels: max_pixels || 10_000_000, # Default to 10M pixels if not specified
)
encoded_uploads.first&.dig(:base64)
end
end,
)
mini_racer_context.attach(
"_upload_get_url",
->(short_url) do
in_attached_function do
return nil if short_url.blank?
sha1 = ::Upload.sha1_from_short_url(short_url)
return nil if sha1.blank?
upload = ::Upload.find_by(sha1: sha1)
return nil if upload.nil?
# TODO we may need to introduce an API to unsecure, secure uploads
return nil if upload.secure?
GlobalPath.full_cdn_url(upload.url)
end
end,
)
mini_racer_context.attach(
"_upload_create",
->(filename, base_64_content) do
begin
in_attached_function do
# protect against misuse
filename = File.basename(filename)
Tempfile.create(filename) do |file|
file.binmode
file.write(Base64.decode64(base_64_content))
file.rewind
upload =
UploadCreator.new(
file,
filename,
for_private_message: @context.private_message,
).create_for(@bot_user.id)
if upload&.persisted?
{ "id" => upload.id, "short_url" => upload.short_url, "url" => upload.url }
else
error_msg =
upload&.errors&.full_messages&.join(", ") || "Upload creation failed"
{ "error" => error_msg }
end
end
end
rescue => e
{ "error" => e.message }
end
end,
)
end
end
end
end
end