CVE-2024-28242 (published 2024-03-15T20:15Z)

Discourse is an open source platform for community discussion. In affected versions an attacker can learn that secret categories exist when they have backgrounds set. The issue is patched in the latest stable, beta and tests-passed version of Discourse. Users are advised to upgrade. Users unable to upgrade should temporarily remove category backgrounds.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/discourse/discourse
commit_details: repo_url: https://github.com/discourse/discourse
author: Bianca Nenciu
author_date: 2024-03-11 19:25:31+02:00
msg: SECURITY: Generate more category CSS on client

This commit moves the generation of category background CSS from the
server side to the client side. This simplifies the server side code
because it does not need to check which categories are visible to the
current user.
repository: repo_url: https://github.com/discourse/discourse
repo_name: discourse/discourse
description: A platform for community discussion. Free, open, simple.
repo_language: Ruby
owner: discourse
file_changes: filename: category-background-css-generator.js
change_type: ModificationType.ADD
diff: @@ -0,0 +1,55 @@
+import { getURLWithCDN } from "discourse-common/lib/get-url";
+
+export default {
+  after: "register-hashtag-types",
+
+  initialize(owner) {
+    this.session = owner.lookup("service:session");
+    this.site = owner.lookup("service:site");
+
+    if (!this.site.categories?.length) {
+      return;
+    }
+
+    const css = [];
+    const darkCss = [];
+
+    this.site.categories.forEach((category) => {
+      const lightUrl = category.uploaded_background?.url;
+      const darkUrl =
+        this.session.defaultColorSchemeIsDark || this.session.darkModeAvailable
+          ? category.uploaded_background_dark?.url
+          : null;
+      const defaultUrl =
+        darkUrl && this.session.defaultColorSchemeIsDark ? darkUrl : lightUrl;
+
+      if (defaultUrl) {
+        const url = getURLWithCDN(defaultUrl);
+        css.push(
+          `body.category-${category.fullSlug} { background-image: url(${url}); }`
+        );
+      }
+
+      if (darkUrl && defaultUrl !== darkUrl) {
+        const url = getURLWithCDN(darkUrl);
+        darkCss.push(
+          `body.category-${category.fullSlug} { background-image: url(${url}); }`
+        );
+      }
+    });
+
+    if (darkCss.length > 0) {
+      css.push("@media (prefers-color-scheme: dark) {", ...darkCss, "}");
+    }
+
+    const cssTag = document.createElement("style");
+    cssTag.type = "text/css";
+    cssTag.id = "category-background-css-generator";
+    cssTag.innerHTML = css.join("\n");
+    document.head.appendChild(cssTag);
+  },
+
+  teardown() {
+    document.querySelector("#category-background-css-generator")?.remove();
+  },
+};

programming_language: TypeScript
filename: category-background-css-generator-test.js
change_type: ModificationType.ADD
diff: @@ -0,0 +1,132 @@
+import { visit } from "@ember/test-helpers";
+import { test } from "qunit";
+import Session from "discourse/models/session";
+import { acceptance } from "discourse/tests/helpers/qunit-helpers";
+
+const SITE_DATA = {
+  categories: [
+    {
+      id: 1,
+      color: "ff0000",
+      text_color: "ffffff",
+      name: "category1",
+      slug: "foo",
+      uploaded_background: {
+        id: 54,
+        url: "/uploads/default/original/1X/c5c84b16ebf745ab848d1498267c541facbf1ff0.png",
+        width: 1024,
+        height: 768,
+      },
+    },
+    {
+      id: 2,
+      color: "333",
+      text_color: "ffffff",
+      name: "category2",
+      slug: "bar",
+      uploaded_background_dark: {
+        id: 25,
+        url: "/uploads/default/original/1X/f9fdb0ad108f2aed178c40f351bbb2c7cb2571e3.png",
+        width: 1024,
+        height: 768,
+      },
+    },
+    {
+      id: 4,
+      color: "2B81AF",
+      text_color: "ffffff",
+      parent_category_id: 1,
+      name: "category3",
+      slug: "baz",
+      uploaded_background: {
+        id: 11,
+        url: "/uploads/default/original/1X/684c104edc18a7e9cef1fa31f41215f3eec5d92b.png",
+        width: 1024,
+        height: 768,
+      },
+      uploaded_background_dark: {
+        id: 19,
+        url: "/uploads/default/original/1X/89b1a2641e91604c32b21db496be11dba7a253e6.png",
+        width: 1024,
+        height: 768,
+      },
+    },
+  ],
+};
+
+acceptance("Category Background CSS Generator", function (needs) {
+  needs.user();
+  needs.site(SITE_DATA);
+
+  test("CSS classes are generated", async function (assert) {
+    await visit("/");
+
+    assert.equal(
+      document.querySelector("#category-background-css-generator").innerHTML,
+      "body.category-foo { background-image: url(/uploads/default/original/1X/c5c84b16ebf745ab848d1498267c541facbf1ff0.png); }\n" +
+        "body.category-foo-baz { background-image: url(/uploads/default/original/1X/684c104edc18a7e9cef1fa31f41215f3eec5d92b.png); }"
+    );
+  });
+});
+
+acceptance("Category Background CSS Generator (dark)", function (needs) {
+  needs.user();
+  needs.site(SITE_DATA);
+
+  needs.hooks.beforeEach(function () {
+    const session = Session.current();
+    session.set("darkModeAvailable", true);
+    session.set("defaultColorSchemeIsDark", false);
+  });
+
+  needs.hooks.afterEach(function () {
+    const session = Session.current();
+    session.set("darkModeAvailable", null);
+    session.set("defaultColorSchemeIsDark", null);
+  });
+
+  test("CSS classes are generated", async function (assert) {
+    await visit("/");
+
+    assert.equal(
+      document.querySelector("#category-background-css-generator").innerHTML,
+      "body.category-foo { background-image: url(/uploads/default/original/1X/c5c84b16ebf745ab848d1498267c541facbf1ff0.png); }\n" +
+        "body.category-foo-baz { background-image: url(/uploads/default/original/1X/684c104edc18a7e9cef1fa31f41215f3eec5d92b.png); }\n" +
+        "@media (prefers-color-scheme: dark) {\n" +
+        "body.category-bar { background-image: url(/uploads/default/original/1X/f9fdb0ad108f2aed178c40f351bbb2c7cb2571e3.png); }\n" +
+        "body.category-foo-baz { background-image: url(/uploads/default/original/1X/89b1a2641e91604c32b21db496be11dba7a253e6.png); }\n" +
+        "}"
+    );
+  });
+});
+
+acceptance(
+  "Category Background CSS Generator (dark is default)",
+  function (needs) {
+    needs.user();
+    needs.site(SITE_DATA);
+
+    needs.hooks.beforeEach(function () {
+      const session = Session.current();
+      session.set("darkModeAvailable", true);
+      session.set("defaultColorSchemeIsDark", true);
+    });
+
+    needs.hooks.afterEach(function () {
+      const session = Session.current();
+      session.set("darkModeAvailable", null);
+      session.set("defaultColorSchemeIsDark", null);
+    });
+
+    test("CSS classes are generated", async function (assert) {
+      await visit("/");
+
+      assert.equal(
+        document.querySelector("#category-background-css-generator").innerHTML,
+        "body.category-foo { background-image: url(/uploads/default/original/1X/c5c84b16ebf745ab848d1498267c541facbf1ff0.png); }\n" +
+          "body.category-bar { background-image: url(/uploads/default/original/1X/f9fdb0ad108f2aed178c40f351bbb2c7cb2571e3.png); }\n" +
+          "body.category-foo-baz { background-image: url(/uploads/default/original/1X/89b1a2641e91604c32b21db496be11dba7a253e6.png); }"
+      );
+    });
+  }
+);

programming_language: JavaScript
filename: compiler.rb
change_type: ModificationType.MODIFY
diff: @@ -34,7 +34,6 @@ module Stylesheet
         when Stylesheet::Manager::COLOR_SCHEME_STYLESHEET
           file += importer.import_color_definitions
           file += importer.import_wcag_overrides
-          file += importer.category_backgrounds(options[:color_scheme_id])
           file += importer.font
         end
       end

programming_language: Ruby
filename: importer.rb
change_type: ModificationType.MODIFY
diff: @@ -95,20 +95,6 @@ module Stylesheet
       contents
     end
 
-    def category_backgrounds(color_scheme_id)
-      is_dark_color_scheme =
-        color_scheme_id.present? && ColorScheme.find_by_id(color_scheme_id)&.is_dark?
-
-      contents = +""
-      Category
-        .where("uploaded_background_id IS NOT NULL")
-        .each do |c|
-          contents << category_css(c, is_dark_color_scheme) if c.uploaded_background&.url.present?
-        end
-
-      contents
-    end
-
     def import_color_definitions
       contents = +""
       DiscoursePluginRegistry.color_definition_stylesheets.each do |name, path|
@@ -220,22 +206,6 @@ module Stylesheet
       @theme == :nil ? nil : @theme
     end
 
-    def category_css(category, is_dark_color_scheme)
-      full_slug = category.full_slug.split("-")[0..-2].join("-")
-
-      # in case we're using a dark color scheme, we define the background using the dark image
-      # if one is available. Otherwise, we use the light image by default.
-      if is_dark_color_scheme && category.uploaded_background_dark&.url.present?
-        return category_background_css(full_slug, category.uploaded_background_dark.url)
-      end
-
-      category_background_css(full_slug, category.uploaded_background.url)
-    end
-
-    def category_background_css(full_slug, background_url)
-      "body.category-#{full_slug} { background-image: url(#{upload_cdn_path(background_url)}) }"
-    end
-
     def font_css(font)
       contents = +""
 

programming_language: Ruby
filename: manager.rb
change_type: ModificationType.MODIFY
diff: @@ -7,7 +7,7 @@ module Stylesheet
 end
 
 class Stylesheet::Manager
-  BASE_COMPILER_VERSION = 1
+  BASE_COMPILER_VERSION = 2
 
   CACHE_PATH = "tmp/stylesheet-cache"
   private_constant :CACHE_PATH

programming_language: Ruby
filename: builder.rb
change_type: ModificationType.MODIFY
diff: @@ -240,20 +240,13 @@ class Stylesheet::Manager::Builder
   def color_scheme_digest
     cs = @color_scheme || theme&.color_scheme
 
-    categories_updated =
-      Stylesheet::Manager
-        .cache
-        .defer_get_set("categories_updated") do
-          Category.where("uploaded_background_id IS NOT NULL").pluck(:updated_at).map(&:to_i).sum
-        end
-
     fonts = "#{SiteSetting.base_font}-#{SiteSetting.heading_font}"
 
     digest_string = "#{current_hostname}-"
-    if cs || categories_updated > 0
+    if cs
       theme_color_defs = resolve_baked_field(:common, :color_definitions)
       digest_string +=
-        "#{RailsMultisite::ConnectionManagement.current_db}-#{cs&.id}-#{cs&.version}-#{theme_color_defs}-#{Stylesheet::Manager.fs_asset_cachebuster}-#{categories_updated}-#{fonts}"
+        "#{RailsMultisite::ConnectionManagement.current_db}-#{cs&.id}-#{cs&.version}-#{theme_color_defs}-#{Stylesheet::Manager.fs_asset_cachebuster}-#{fonts}"
     else
       digest_string += "defaults-#{Stylesheet::Manager.fs_asset_cachebuster}-#{fonts}"
 

programming_language: Ruby
filename: importer_spec.rb
change_type: ModificationType.MODIFY
diff: @@ -7,151 +7,6 @@ RSpec.describe Stylesheet::Importer do
     Stylesheet::Compiler.compile_asset(name, options)[0]
   end
 
-  describe "#category_backgrounds" do
-    it "uses the correct background image based in the color scheme" do
-      background = Fabricate(:upload)
-      background_dark = Fabricate(:upload)
-
-      parent_category = Fabricate(:category)
-      category =
-        Fabricate(
-          :category,
-          parent_category_id: parent_category.id,
-          uploaded_background: background,
-          uploaded_background_dark: background_dark,
-        )
-
-      # light color schemes
-      ["Neutral", "Shades of Blue", "WCAG", "Summer", "Solarized Light"].each do |scheme_name|
-        scheme = ColorScheme.create_from_base(name: "Light Test", base_scheme_id: scheme_name)
-
-        compiled_css = compile_css("color_definitions", { color_scheme_id: scheme.id })
-
-        expect(compiled_css).to include(
-          "body.category-#{parent_category.slug}-#{category.slug}{background-image:url(#{background.url})}",
-        )
-        expect(compiled_css).not_to include(background_dark.url)
-      end
-
-      # dark color schemes
-      [
-        "Dark",
-        "Grey Amber",
-        "Latte",
-        "Dark Rose",
-        "WCAG Dark",
-        "Dracula",
-        "Solarized Dark",
-      ].each do |scheme_name|
-        scheme = ColorScheme.create_from_base(name: "Light Test", base_scheme_id: scheme_name)
-
-        compiled_css = compile_css("color_definitions", { color_scheme_id: scheme.id })
-
-        expect(compiled_css).not_to include(background.url)
-        expect(compiled_css).to include(
-          "body.category-#{parent_category.slug}-#{category.slug}{background-image:url(#{background_dark.url})}",
-        )
-      end
-    end
-
-    it "applies CDN to background category images" do
-      expect(compile_css("color_definitions")).to_not include("body.category-")
-
-      background = Fabricate(:upload)
-      background_dark = Fabricate(:upload)
-
-      parent_category = Fabricate(:category)
-      category =
-        Fabricate(
-          :category,
-          parent_category_id: parent_category.id,
-          uploaded_background: background,
-          uploaded_background_dark: background_dark,
-        )
-
-      compiled_css = compile_css("color_definitions")
-      expect(compiled_css).to include(
-        "body.category-#{parent_category.slug}-#{category.slug}{background-image:url(#{background.url})}",
-      )
-
-      GlobalSetting.stubs(:cdn_url).returns("//awesome.cdn")
-      compiled_css = compile_css("color_definitions")
-      expect(compiled_css).to include(
-        "body.category-#{parent_category.slug}-#{category.slug}{background-image:url(//awesome.cdn#{background.url})}",
-      )
-    end
-
-    it "applies CDN to dark background category images" do
-      scheme = ColorScheme.create_from_base(name: "Dark Test", base_scheme_id: "Dark")
-      expect(compile_css("color_definitions", { color_scheme_id: scheme.id })).to_not include(
-        "body.category-",
-      )
-
-      background = Fabricate(:upload)
-      background_dark = Fabricate(:upload)
-
-      parent_category = Fabricate(:category)
-      category =
-        Fabricate(
-          :category,
-          parent_category_id: parent_category.id,
-          uploaded_background: background,
-          uploaded_background_dark: background_dark,
-        )
-
-      compiled_css = compile_css("color_definitions", { color_scheme_id: scheme.id })
-      expect(compiled_css).to include(
-        "body.category-#{parent_category.slug}-#{category.slug}{background-image:url(#{background_dark.url})}",
-      )
-
-      GlobalSetting.stubs(:cdn_url).returns("//awesome.cdn")
-      compiled_css = compile_css("color_definitions", { color_scheme_id: scheme.id })
-      expect(compiled_css).to include(
-        "body.category-#{parent_category.slug}-#{category.slug}{background-image:url(//awesome.cdn#{background_dark.url})}",
-      )
-    end
-
-    it "applies S3 CDN to background category images" do
-      setup_s3
-      SiteSetting.s3_use_iam_profile = true
-      SiteSetting.s3_upload_bucket = "test"
-      SiteSetting.s3_region = "ap-southeast-2"
-      SiteSetting.s3_cdn_url = "https://s3.cdn"
-
-      background = Fabricate(:upload_s3)
-      category = Fabricate(:category, uploaded_background: background)
-
-      compiled_css = compile_css("color_definitions")
-      expect(compiled_css).to include(
-        "body.category-#{category.slug}{background-image:url(https://s3.cdn/original",
-      )
-    end
-
-    it "applies S3 CDN to dark background category images" do
-      scheme = ColorScheme.create_from_base(name: "Dark Test", base_scheme_id: "WCAG Dark")
-
-      setup_s3
-      SiteSetting.s3_use_iam_profile = true
-      SiteSetting.s3_upload_bucket = "test"
-      SiteSetting.s3_region = "ap-southeast-2"
-      SiteSetting.s3_cdn_url = "https://s3.cdn"
-
-      background = Fabricate(:upload_s3)
-      background_dark = Fabricate(:upload_s3)
-      category =
-        Fabricate(
-          :category,
-          uploaded_background: background,
-          uploaded_background_dark: background_dark,
-        )
-
-      compiled_css = compile_css("color_definitions", { color_scheme_id: scheme.id })
-      expect(compiled_css).to include(
-        "body.category-#{category.slug}{background-image:url(https://s3.cdn/original",
-      )
-    end
-  end
-
   describe "#font" do
     it "includes font variable" do
       default_font = ":root{--font-family: Arial, sans-serif}"

programming_language: Ruby
filename: manager_spec.rb
change_type: ModificationType.MODIFY
diff: @@ -483,29 +483,6 @@ RSpec.describe Stylesheet::Manager do
   describe "color_scheme_digest" do
     fab!(:theme)
 
-    it "changes with category background image" do
-      category1 = Fabricate(:category, uploaded_background_id: 123, updated_at: 1.week.ago)
-      category2 = Fabricate(:category, uploaded_background_id: 456, updated_at: 2.days.ago)
-
-      manager = manager(theme.id)
-
-      builder =
-        Stylesheet::Manager::Builder.new(target: :desktop_theme, theme: theme, manager: manager)
-
-      digest1 = builder.color_scheme_digest
-
-      category2.update!(uploaded_background_id: 789, updated_at: 1.day.ago)
-
-      digest2 = builder.color_scheme_digest
-      expect(digest2).to_not eq(digest1)
-
-      category1.update!(uploaded_background_id: nil, updated_at: 5.minutes.ago)
-
-      digest3 = builder.color_scheme_digest
-      expect(digest3).to_not eq(digest2)
-      expect(digest3).to_not eq(digest1)
-    end
-
     it "updates digest when updating a color scheme" do
       scheme = ColorScheme.create_from_base(name: "Neutral", base_scheme_id: "Neutral")
       manager = manager(theme.id)

programming_language: Ruby