discourse/app/controllers/post_actions_controller.rb
Alan Guo Xiang Tan d3cb203fea SECURITY: fix is_warning type coercion bypass in PostActionsController
What is the problem?

- `PostActionsController#create` passes `params[:is_warning]` to
  `PostActionCreator` without casting it — JSON requests preserve the
  boolean type, so `is_warning` arrives as Ruby `true` instead of
  string `"true"`
- `PostActionCreator#perform` calls `#post_can_act?`, which delegates
  to `PostGuardian#post_can_act?` with `is_warning: @is_warning`
- The guardian compares `opts[:is_warning] == "true"` — since Ruby's
  `true == "true"` is `false`, the non-staff check does not trigger
  and `#post_can_act?` returns `true`
- With the authorization check passed, `#perform` continues to
  `#create_message_creator`, which forwards `is_warning: @is_warning`
  to `PostCreator` and ultimately `TopicCreator#create_warning` — where
  boolean `true` is truthy, creating a `UserWarning` record

What is the solution?

- Cast `params[:is_warning]` with `ActiveModel::Type::Boolean.new.cast`
  in `PostActionsController#create` before passing it to
  `PostActionCreator`, matching the pattern already used in
  `PostsController`
- Simplify the guardian check in `PostGuardian#post_can_act?` from a
  string comparison to a direct truthiness check, since the controller
  now guarantees a proper boolean
- Add request specs covering both form-encoded and JSON boolean variants
  of the `is_warning` param for both `true` and `false` values
2026-03-19 15:21:28 +00:00

90 lines
2.3 KiB
Ruby
Vendored

# frozen_string_literal: true
class PostActionsController < ApplicationController
requires_login
before_action :fetch_post_from_params
before_action :fetch_post_action_type_id_from_params
def create
raise Discourse::NotFound if @post.blank?
creator =
PostActionCreator.new(
current_user,
@post,
@post_action_type_id,
is_warning: ActiveModel::Type::Boolean.new.cast(params[:is_warning]),
message: params[:message],
take_action: params[:take_action] == "true",
flag_topic: params[:flag_topic] == "true",
queue_for_review: params[:queue_for_review] == "true",
)
result = creator.perform
if result.failed?
render_json_error(result)
else
# We need to reload or otherwise we are showing the old values on the front end
@post.reload
if @post_action_type_id == PostActionType.types[:like]
limiter = result.post_action.post_action_rate_limiter
response.headers["Discourse-Actions-Remaining"] = limiter.remaining.to_s
response.headers["Discourse-Actions-Max"] = limiter.max.to_s
end
render_post_json(@post, add_raw: false)
end
end
def destroy
result =
PostActionDestroyer.new(
current_user,
Post.find_by(id: params[:id].to_i),
@post_action_type_id,
).perform
if result.failed?
render_json_error(result)
else
if !guardian.can_see_post?(result.post)
head :no_content
else
render_post_json(result.post, add_raw: false)
end
end
end
private
def fetch_post_from_params
params.require(:id)
flag_topic = params[:flag_topic]
flag_topic = flag_topic && (flag_topic == true || flag_topic == "true")
post_id =
if flag_topic
begin
Topic.find(params[:id]).posts.first.id
rescue StandardError
raise Discourse::NotFound
end
else
params[:id]
end
finder = Post.where(id: post_id)
# Include deleted posts if the user is a staff
finder = finder.with_deleted if guardian.is_staff?
@post = finder.first
end
def fetch_post_action_type_id_from_params
params.require(:post_action_type_id)
@post_action_type_id = params[:post_action_type_id].to_i
end
end