CVE-2022-24804 (Ruby, published 2022-04-11T20:15Z)
Description: value: Discourse is an open source platform for community discussion. In stable versions prior to 2.8.3 and beta versions prior 2.9.0.beta4 erroneously expose groups. When a group with restricted visibility has been used to set the permissions of a category, the name of the group is leaked to any user that is able to see the category. To workaround the problem, a site administrator can remove groups with restricted visibility from any category's permissions setting.
Severity: MEDIUM (CVSS v3 base score: 5.3)
Weakness: CWE-276 \u2014 Incorrect Default Permissions
During installation, installed file permissions are set to allow anyone to modify those files.
Repository: https://github.com/discourse/discourse
Files changed:
app/assets/javascripts/discourse/app/controllers/share-topic.js
app/assets/javascripts/discourse/app/models/category.js
app/assets/javascripts/discourse/tests/acceptance/share-topic-test.js
app/assets/javascripts/discourse/tests/acceptance/topic-test.js
app/controllers/categories_controller.rb
app/serializers/category_serializer.rb
config/routes.rb
spec/requests/categories_controller_spec.rb
spec/serializers/category_serializer_spec.rb
Commit message: SECURITY: Category group permissions leaked to normal users.

After this commit, category group permissions can only be seen by users
that are allowed to manage a category. In the past, we inadvertently
included a category's group permissions settings in `CategoriesController#show`
and `CategoriesController#find_by_slug` endpoints for normal users when
those settings are only a concern to users that can manage a category.


Vulnerable code:
      Category.reloadBySlugPath(this.model.slug).then((result) => {
        const groups = result.category.group_permissions.mapBy("group_name");
        if (groups && !groups.any((group) => group === "everyone")) {
              count: groups.length,
              groupNames: groups.join(", "),
    server.get("/c/feature/find_by_slug.json", () =>
      helper.response(200, CategoryFixtures["/c/1/show.json"])
        count: 1,
        groupNames: "moderators",
  needs.pretender((server, helper) => {
    server.get("/c/feature/find_by_slug.json", () =>
      helper.response(200, CategoryFixtures["/c/1/show.json"])
    );
  });

  requires_login except: [:index, :categories_and_latest, :categories_and_top, :show, :redirect, :find_by_slug]
  before_action :fetch_category, only: [:show, :update, :destroy]
    context "category without group permissions configured" do
      it "returns the right category group permissions for an anon user" do
        json = described_class.new(category, scope: Guardian.new, root: false).as_json
        expect(json[:group_permissions]).to eq([
          { permission_type: CategoryGroup.permission_types[:full], group_name: Group[:everyone]&.name }
        ])
    context "category with group permissions configured" do
      fab!(:private_group) { Fabricate(:group, visibility_level: Group.visibility_levels[:staff], name: 'bbb') }
      fab!(:user_group) do
        Fabricate(:group, visibility_level: Group.visibility_levels[:members], name: 'ccc').tap do |g|
          g.add(user)
        end
      end

      before do
        group.update!(name: 'aaa')

        category.set_permissions(
          :everyone => :readonly,
          group.name => :readonly,
          user_group.name => :full,
          private_group.name => :full,
        )

        category.save!
      end

      it "returns the right category group permissions for an anon user" do
        json = described_class.new(category, scope: Guardian.new, root: false).as_json
        expect(json[:group_permissions]).to eq([
          { permission_type: CategoryGroup.permission_types[:readonly], group_name: group.name },
        ])
      end
      it "returns the right category group permissions for a regular user ordered by ascending group name" do
        json = described_class.new(category, scope: Guardian.new(user), root: false).as_json
        expect(json[:group_permissions]).to eq([
          { permission_type: CategoryGroup.permission_types[:readonly], group_name: group.name },
          { permission_type: CategoryGroup.permission_types[:full], group_name: user_group.name },
        ])
      end
      it "returns the right category group permission for a staff user ordered by ascending group name" do
        json = described_class.new(category, scope: Guardian.new(admin), root: false).as_json
        expect(json[:group_permissions]).to eq([
          { permission_type: CategoryGroup.permission_types[:readonly], group_name: group.name },
          { permission_type: CategoryGroup.permission_types[:full], group_name: private_group.name },
          { permission_type: CategoryGroup.permission_types[:full], group_name: user_group.name },
          { permission_type: CategoryGroup.permission_types[:readonly], group_name: 'everyone' },
        ])
      end
      it "returns the group permissions for everyone group too" do
        category.set_permissions(everyone: :readonly)
        category.save!
        json = described_class.new(category, scope: Guardian.new(admin), root: false).as_json
        expect(json[:group_permissions]).to eq([
          { permission_type: CategoryGroup.permission_types[:readonly], group_name: 'everyone' },
        ])
      end

Fixed code:
      Category.fetchVisibleGroups(this.model.id).then((result) => {
        if (result.groups.length > 0) {
              count: result.groups.length,
              groupNames: result.groups.join(", "),
  fetchVisibleGroups(id) {
    return ajax(`/c/${id}/visible_groups.json`);
  },

    server.get(`/c/2481/visible_groups.json`, () =>
      helper.response(200, {
        groups: ["group_name_1", "group_name_2"],
      })
    );

    server.get(`/c/2/visible_groups.json`, () =>
      helper.response(200, {
        groups: [],
      })

        count: 2,
        groupNames: "group_name_1, group_name_2",
    server.get("/c/2/visible_groups.json", () =>
      helper.response(200, {
        groups: [],
      })
    );

  requires_login except: [:index, :categories_and_latest, :categories_and_top, :show, :redirect, :find_by_slug, :visible_groups]
  before_action :fetch_category, only: [:show, :update, :destroy, :visible_groups]

  def visible_groups
    @guardian.ensure_can_see!(@category)
    render json: success_json.merge(groups: @category.groups.merge(Group.visible_groups(current_user)).pluck("name"))
  end

    raise Discourse::NotFound if @category.blank?
  def include_group_permissions?
    scope&.can_edit?(object)
  end

    get "c/:id/visible_groups" => "categories#visible_groups"

  describe '#visible_groups' do
    fab!(:public_group) { Fabricate(:group, visibility_level: Group.visibility_levels[:public], name: 'aaa') }
    fab!(:private_group) { Fabricate(:group, visibility_level: Group.visibility_levels[:staff], name: 'bbb') }
    fab!(:user_only_group) { Fabricate(:group, visibility_level: Group.visibility_levels[:members], name: 'ccc') }

    it 'responds with 404 when id param is invalid' do
      get "/c/-9999/visible_groups.json"

      expect(response.status).to eq(404)
    end

    it "responds with 403 when category is restricted to the current user" do
      category.set_permissions(private_group.name => :full)
      category.save!

      get "/c/#{category.id}/visible_groups.json"

      expect(response.status).to eq(403)
    end

    it "returns the names of the groups that are visible to an admin" do
      sign_in(admin)

      category.set_permissions(
        "everyone" => :readonly,
        private_group.name => :full,
        public_group.name => :full,
        user_only_group.name => :full,
      )

      category.save!

      get "/c/#{category.id}/visible_groups.json"

      expect(response.status).to eq(200)
      expect(response.parsed_body["groups"]).to eq([public_group.name, private_group.name, user_only_group.name])
    end

    it "returns the names of the groups that are visible to a user and excludes the everyone group" do
      sign_in(user)

      category.set_permissions(
        "everyone" => :readonly,
        private_group.name => :full,
        public_group.name => :full,
        user_only_group.name => :full,
      )

      category.save!

      get "/c/#{category.id}/visible_groups.json"

      expect(response.status).to eq(200)
      expect(response.parsed_body["groups"]).to eq([public_group.name])
    end
  end
    fab!(:private_group) { Fabricate(:group, visibility_level: Group.visibility_levels[:staff], name: 'bbb') }
    fab!(:user_group) do
      Fabricate(:group, visibility_level: Group.visibility_levels[:members], name: 'ccc').tap do |g|
        g.add(user)
    before do
      group.update!(name: 'aaa')
      category.set_permissions(
        :everyone => :readonly,
        group.name => :readonly,
        user_group.name => :full,
        private_group.name => :full,
      )
      category.save!
    end
    it "does not include the attribute for an anon user" do
      json = described_class.new(category, scope: Guardian.new, root: false).as_json
      expect(json[:group_permissions]).to eq(nil)
    end
    it "does not include the attribute for a regular user" do
      json = described_class.new(category, scope: Guardian.new(user), root: false).as_json
      expect(json[:group_permissions]).to eq(nil)
    end
    it "returns the right category group permissions for a user that can edit the category" do
      SiteSetting.moderators_manage_categories_and_groups = true
      user.update!(moderator: true)
      json = described_class.new(category, scope: Guardian.new(user), root: false).as_json
      expect(json[:group_permissions]).to eq([
        { permission_type: CategoryGroup.permission_types[:readonly], group_name: group.name },
        { permission_type: CategoryGroup.permission_types[:full], group_name: private_group.name },
        { permission_type: CategoryGroup.permission_types[:full], group_name: user_group.name },
        { permission_type: CategoryGroup.permission_types[:readonly], group_name: 'everyone' },
      ])