discourse/plugins/discourse-ai/app/jobs/regular/stream_post_helper.rb
Nat 7404dd5a1d SECURITY: Prevent 'Explain' feature from exposing hidden posts
The AI 'Explain' feature builds a prompt that includes the reply-to post's raw content, but only checks `can_see?` on the post being explained, not the parent. If post B replies to a hidden post A, a user who triggers explain on post B gets post A's content fed into the prompt (potentially surfaced in the AI response).

This is a problem if the hidden post contains sensitive content like credentials that were hidden by a mod but not yet redacted.

This PR ensures we check replies are also `can_see?` and escapes interpolated values with `ERB::Util.html_escape` to prevent post content from breaking out of the prompt's XML tag structure (e.g. raw containing `</context><replyTo>injected</replyTo>`)

<img width="600"  alt="Screenshot 2026-04-29 at 6 32 12 PM" src="https://github.com/user-attachments/assets/e1cd535b-bd0f-4411-ab34-582d66fbf518" />

https://github.com/discourse/discourse/security/advisories/GHSA-7h76-fwxc-j586
2026-05-19 00:26:04 +01:00

73 lines
2 KiB
Ruby
Vendored

# frozen_string_literal: true
module Jobs
class StreamPostHelper < ::Jobs::Base
sidekiq_options retry: false
def execute(args)
return unless post = Post.includes(:topic).find_by(id: args[:post_id])
return unless user = User.find_by(id: args[:user_id])
return unless args[:text]
return unless args[:progress_channel]
return unless args[:client_id]
topic = post.topic
reply_to = post.reply_to_post
guardian = user.guardian
return unless guardian.can_see?(post)
helper_mode = args[:prompt]
if helper_mode == DiscourseAi::AiHelper::Assistant::EXPLAIN
input = <<~TEXT.strip
<term>#{escape_prompt_tag_value(args[:text])}</term>
<context>#{escape_prompt_tag_value(post.raw)}</context>
<topic>#{escape_prompt_tag_value(topic.title)}</topic>
#{reply_to && guardian.can_see?(reply_to) ? "<replyTo>#{escape_prompt_tag_value(reply_to.raw)}</replyTo>" : nil}
TEXT
else
input = args[:text]
end
begin
DiscourseAi::AiHelper::Assistant.new.stream_prompt(
helper_mode,
input,
user,
args[:progress_channel],
custom_prompt: args[:custom_prompt],
client_id: args[:client_id],
)
rescue LlmCreditAllocation::CreditLimitExceeded => e
publish_error(args[:progress_channel], user, e)
end
end
private
def escape_prompt_tag_value(value)
ERB::Util.html_escape(value.to_s)
end
def publish_error(channel, user, exception)
allocation = exception.allocation
details = {}
if allocation
details[:reset_time_relative] = allocation.relative_reset_time
details[:reset_time_absolute] = allocation.formatted_reset_time
end
payload = {
error: true,
error_type: "credit_limit_exceeded",
message: exception.message,
details: details,
done: true,
}
MessageBus.publish(channel, payload, user_ids: [user.id], max_backlog_age: 60)
end
end
end