Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions app/assets/javascripts/discourse/controllers/user.js.es6
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,6 @@ export default ObjectController.extend(CanCheckEmails, {

collapsedInfo: Em.computed.not('indexStream'),

websiteName: function() {
var website = this.get('model.website');
if (Em.isEmpty(website)) { return; }
return website.split("/")[2];
}.property('model.website'),

linkWebsite: Em.computed.not('model.isBasic'),

removeNoFollow: function() {
Expand Down
2 changes: 1 addition & 1 deletion app/assets/javascripts/discourse/models/user.js.es6
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const User = RestModel.extend({
/**
This user's profile background(in CSS).

@property websiteName
@property profileBackground
@type {String}
**/
profileBackground: function() {
Expand Down
6 changes: 3 additions & 3 deletions app/assets/javascripts/discourse/templates/user/user.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,12 @@
{{/if}}
<h3>
{{#if model.location}}{{fa-icon "map-marker"}} {{model.location}}{{/if}}
{{#if websiteName}}
{{#if model.website_name}}
{{fa-icon "globe"}}
{{#if linkWebsite}}
<a href={{model.website}} rel={{unless removeNoFollow 'nofollow'}} target="_blank">{{websiteName}}</a>
<a href={{model.website}} rel={{unless removeNoFollow 'nofollow'}} target="_blank">{{model.website_name}}</a>
{{else}}
<span title={{model.website}}>{{websiteName}}</span>
<span title={{model.website}}>{{model.website_name}}</span>
{{/if}}
{{/if}}
</h3>
Expand Down
21 changes: 21 additions & 0 deletions app/serializers/user_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def self.untrusted_attributes(*attrs)
:bio_cooked,
:created_at,
:website,
:website_name,
:profile_background,
:card_background,
:location,
Expand Down Expand Up @@ -133,6 +134,26 @@ def website
object.user_profile.website
end

def website_name
website_host = URI(website.to_s).host rescue nil
discourse_host = Discourse.current_hostname
return if website_host.nil?
if website_host == discourse_host
# example.com == example.com
website_host + URI(website.to_s).path
elsif (website_host.split('.').length == discourse_host.split('.').length) && discourse_host.split('.').length > 2
# www.example.com == forum.example.com
website_host.split('.')[1..-1].join('.') == discourse_host.split('.')[1..-1].join('.') ? website_host + URI(website.to_s).path : website_host
else
# example.com == forum.example.com
discourse_host.ends_with?("." << website_host) ? website_host + URI(website.to_s).path : website_host
end
end

def include_website_name
website.present?
Comment on lines +138 to +154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The website_name attribute is always considered for serialization, even when the user's website field is supposed to be hidden (e.g. TL0 users viewed anonymously) or blank, because the include hook is misnamed (include_website_name instead of include_website_name?) and does not respect scope.restrict_user_fields?, which can leak website information and produce inconsistent JSON; update the guard to check both website.present? and !scope.restrict_user_fields?(object) and use the correct include_... ? naming so the serializer honours it. [security]

Severity Level: Critical 🚨
- ❌ TL0 anonymous user website hidden but website_name exposed.
- ❌ /users/:username.json leaks restricted website URL data.
- ⚠️ HTML profile preloaded JSON also exposes website_name.
Suggested change
website_host = URI(website.to_s).host rescue nil
discourse_host = Discourse.current_hostname
return if website_host.nil?
if website_host == discourse_host
# example.com == example.com
website_host + URI(website.to_s).path
elsif (website_host.split('.').length == discourse_host.split('.').length) && discourse_host.split('.').length > 2
# www.example.com == forum.example.com
website_host.split('.')[1..-1].join('.') == discourse_host.split('.')[1..-1].join('.') ? website_host + URI(website.to_s).path : website_host
else
# example.com == forum.example.com
discourse_host.ends_with?("." << website_host) ? website_host + URI(website.to_s).path : website_host
end
end
def include_website_name
website.present?
return if website.blank? || scope.restrict_user_fields?(object)
website_host = URI(website.to_s).host rescue nil
discourse_host = Discourse.current_hostname
return if website_host.nil?
if website_host == discourse_host
# example.com == example.com
website_host + URI(website.to_s).path
elsif (website_host.split('.').length == discourse_host.split('.').length) && discourse_host.split('.').length > 2
# www.example.com == forum.example.com
website_host.split('.')[1..-1].join('.') == discourse_host.split('.')[1..-1].join('.') ? website_host + URI(website.to_s).path : website_host
else
# example.com == forum.example.com
discourse_host.ends_with?("." << website_host) ? website_host + URI(website.to_s).path : website_host
end
end
def include_website_name?
website.present? && !scope.restrict_user_fields?(object)
Steps of Reproduction ✅
1. In the Rails app, create or use a TL0 user with a non-blank website, matching the setup
from `spec/serializers/user_serializer_spec.rb:6-12` (user with `trust_level: 0` and a
`user_profile`), but also set `user.user_profile.website = 'http://example.com/user'` as
in the spec context at `spec/serializers/user_serializer_spec.rb:67-70`.

2. Serialize this user as anonymous using the same pattern as the specs: `serializer =
UserSerializer.new(user, scope: Guardian.new, root: false); json = serializer.as_json`
(see `spec/serializers/user_serializer_spec.rb:8-9` where `scope: Guardian.new` represents
an anonymous guardian).

3. Because the user is TL0 and the scope is anonymous, `Guardian#restrict_user_fields?` at
`lib/guardian/user_guardian.rb:58-60` returns true, so the dynamically defined
`include_website?` method (created by `untrusted_attributes` at
`app/serializers/user_serializer.rb:25-33,105-111`) evaluates `return false if
scope.restrict_user_fields?(object)` and suppresses the `website` attribute; this behavior
is asserted for `:website` in the TL0 spec at
`spec/serializers/user_serializer_spec.rb:11-15`.

4. Despite `website` being omitted, the `website_name` attribute is still serialized
because (a) it is listed in `attributes` at `app/serializers/user_serializer.rb:35-43`,
(b) its include hook is misnamed `include_website_name` without a `?` at
`app/serializers/user_serializer.rb:153-155`, so ActiveModel::Serializer does not treat it
as an `include_... ?` predicate, and (c) the `website_name` method at
`app/serializers/user_serializer.rb:137-151` has no `restrict_user_fields?` guard; as a
result, `json[:website]` is absent while `json[:website_name]` still contains
`'example.com/user'`, leaking a field that was intentionally hidden for TL0 anonymous
views.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** app/serializers/user_serializer.rb
**Line:** 138:154
**Comment:**
	*Security: The `website_name` attribute is always considered for serialization, even when the user's website field is supposed to be hidden (e.g. TL0 users viewed anonymously) or blank, because the include hook is misnamed (`include_website_name` instead of `include_website_name?`) and does not respect `scope.restrict_user_fields?`, which can leak website information and produce inconsistent JSON; update the guard to check both `website.present?` and `!scope.restrict_user_fields?(object)` and use the correct `include_... ?` naming so the serializer honours it.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

end

def card_image_badge_id
object.user_profile.card_image_badge.try(:id)
end
Expand Down
21 changes: 19 additions & 2 deletions spec/serializers/user_serializer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,28 @@

context "with filled out website" do
before do
user.user_profile.website = 'http://example.com'
user.user_profile.website = 'http://example.com/user'
end

it "has a website" do
expect(json[:website]).to eq 'http://example.com'
expect(json[:website]).to eq 'http://example.com/user'
end

context "has a website name" do
it "returns website host name when instance domain is not same as website domain" do
Discourse.stubs(:current_hostname).returns('discourse.org')
expect(json[:website_name]).to eq 'example.com'
end

it "returns complete website path when instance domain is same as website domain" do
Discourse.stubs(:current_hostname).returns('example.com')
expect(json[:website_name]).to eq 'example.com/user'
end

it "returns complete website path when website domain is parent of instance domain" do
Discourse.stubs(:current_hostname).returns('forums.example.com')
expect(json[:website_name]).to eq 'example.com/user'
end
end
end

Expand Down