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
7 changes: 7 additions & 0 deletions app/assets/javascripts/admin/adapters/embedding.js.es6
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import RestAdapter from 'discourse/adapters/rest';

export default RestAdapter.extend({
pathFor() {
return "/admin/customize/embedding";
}
});
63 changes: 63 additions & 0 deletions app/assets/javascripts/admin/components/embeddable-host.js.es6
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { bufferedProperty } from 'discourse/mixins/buffered-content';
import computed from 'ember-addons/ember-computed-decorators';
import { on, observes } from 'ember-addons/ember-computed-decorators';
import { popupAjaxError } from 'discourse/lib/ajax-error';

export default Ember.Component.extend(bufferedProperty('host'), {
editToggled: false,
tagName: 'tr',
categoryId: null,

editing: Ember.computed.or('host.isNew', 'editToggled'),

@on('didInsertElement')
@observes('editing')
_focusOnInput() {
Ember.run.schedule('afterRender', () => { this.$('.host-name').focus(); });
},

@computed('buffered.host', 'host.isSaving')
cantSave(host, isSaving) {
return isSaving || Ember.isEmpty(host);
},

actions: {
edit() {
this.set('categoryId', this.get('host.category.id'));
this.set('editToggled', true);
},

save() {
if (this.get('cantSave')) { return; }

const props = this.get('buffered').getProperties('host');
props.category_id = this.get('categoryId');

const host = this.get('host');
host.save(props).then(() => {
host.set('category', Discourse.Category.findById(this.get('categoryId')));
this.set('editToggled', false);
}).catch(popupAjaxError);
},

delete() {
bootbox.confirm(I18n.t('admin.embedding.confirm_delete'), (result) => {
if (result) {
this.get('host').destroyRecord().then(() => {
this.sendAction('deleteHost', this.get('host'));
});
}
});
},

cancel() {
const host = this.get('host');
if (host.get('isNew')) {
this.sendAction('deleteHost', host);
} else {
this.rollbackBuffer();
this.set('editToggled', false);
}
}
}
});
18 changes: 18 additions & 0 deletions app/assets/javascripts/admin/controllers/admin-embedding.js.es6
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export default Ember.Controller.extend({
embedding: null,

actions: {
saveChanges() {
this.get('embedding').update({});
},

addHost() {
const host = this.store.createRecord('embeddable-host');
this.get('embedding.embeddable_hosts').pushObject(host);
},

deleteHost(host) {
this.get('embedding.embeddable_hosts').removeObject(host);
}
}
});
9 changes: 9 additions & 0 deletions app/assets/javascripts/admin/routes/admin-embedding.js.es6
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export default Ember.Route.extend({
model() {
return this.store.find('embedding');
},

setupController(controller, model) {
controller.set('embedding', model);
}
});
1 change: 1 addition & 0 deletions app/assets/javascripts/admin/routes/admin-route-map.js.es6
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default {
this.resource('adminUserFields', { path: '/user_fields' });
this.resource('adminEmojis', { path: '/emojis' });
this.resource('adminPermalinks', { path: '/permalinks' });
this.resource('adminEmbedding', { path: '/embedding' });
});
this.route('api');

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{{#if editing}}
<td>
{{input value=buffered.host placeholder="example.com" enter="save" class="host-name"}}
</td>
<td>
{{category-chooser value=categoryId}}
</td>
<td>
{{d-button icon="check" action="save" class="btn-primary" disabled=cantSave}}
{{d-button icon="times" action="cancel" class="btn-danger" disabled=host.isSaving}}
</td>
{{else}}
<td>{{host.host}}</td>
<td>{{category-badge host.category}}</td>
<td>
{{d-button icon="pencil" action="edit"}}
{{d-button icon="trash-o" action="delete" class='btn-danger'}}
</td>
{{/if}}
1 change: 1 addition & 0 deletions app/assets/javascripts/admin/templates/customize.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
{{nav-item route='adminUserFields' label='admin.user_fields.title'}}
{{nav-item route='adminEmojis' label='admin.emoji.title'}}
{{nav-item route='adminPermalinks' label='admin.permalink.title'}}
{{nav-item route='adminEmbedding' label='admin.embedding.title'}}
{{/admin-nav}}

<div class="admin-container">
Expand Down
15 changes: 15 additions & 0 deletions app/assets/javascripts/admin/templates/embedding.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{{#if embedding.embeddable_hosts}}
<table>
<tr>
<th style='width: 50%'>{{i18n "admin.embedding.host"}}</th>
<th style='width: 30%'>{{i18n "admin.embedding.category"}}</th>
<th style='width: 20%'>&nbsp;</th>
</tr>
{{#each embedding.embeddable_hosts as |host|}}
{{embeddable-host host=host deleteHost="deleteHost"}}
{{/each}}
</table>
{{/if}}

{{d-button label="admin.embedding.add_host" action="addHost" icon="plus" class="btn-primary"}}

4 changes: 2 additions & 2 deletions app/assets/javascripts/discourse/adapters/rest.js.es6
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const ADMIN_MODELS = ['plugin', 'site-customization'];
const ADMIN_MODELS = ['plugin', 'site-customization', 'embeddable-host'];

export function Result(payload, responseJson) {
this.payload = payload;
Expand All @@ -19,7 +19,7 @@ function rethrow(error) {
export default Ember.Object.extend({

basePath(store, type) {
if (ADMIN_MODELS.indexOf(type) !== -1) { return "/admin/"; }
if (ADMIN_MODELS.indexOf(type.replace('_', '-')) !== -1) { return "/admin/"; }
return "/";
},

Expand Down
18 changes: 14 additions & 4 deletions app/assets/javascripts/discourse/models/store.js.es6
Original file line number Diff line number Diff line change
Expand Up @@ -189,14 +189,24 @@ export default Ember.Object.extend({
_hydrateEmbedded(type, obj, root) {
const self = this;
Object.keys(obj).forEach(function(k) {
const m = /(.+)\_id$/.exec(k);
const m = /(.+)\_id(s?)$/.exec(k);
if (m) {
const subType = m[1];
const hydrated = self._lookupSubType(subType, type, obj[k], root);
if (hydrated) {
obj[subType] = hydrated;

if (m[2]) {
const hydrated = obj[k].map(function(id) {
return self._lookupSubType(subType, type, id, root);
});
obj[self.pluralize(subType)] = hydrated || [];
delete obj[k];
} else {
const hydrated = self._lookupSubType(subType, type, obj[k], root);
if (hydrated) {
obj[subType] = hydrated;
delete obj[k];
}
}

}
});
},
Expand Down
34 changes: 34 additions & 0 deletions app/controllers/admin/embeddable_hosts_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
class Admin::EmbeddableHostsController < Admin::AdminController

before_filter :ensure_logged_in, :ensure_staff

def create
save_host(EmbeddableHost.new)
end

def update
host = EmbeddableHost.where(id: params[:id]).first
save_host(host)

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: When updating a host, if the requested record ID does not exist, EmbeddableHost.where(...).first will return nil and save_host(host) will attempt to call methods on nil, causing a 500 error instead of a proper not-found response. [null pointer]

Severity Level: Major ⚠️
- ❌ Admin update host endpoint 500s for missing records.
- ⚠️ Admin embedding UI breaks when editing deleted host.
Suggested change
save_host(host)
raise Discourse::NotFound unless host
Steps of Reproduction ✅
1. Log in as a staff user and open the admin embedding UI at `GET
/admin/customize/embedding` (routed via `config/routes.rb:138-139` to
`Admin::EmbeddingController#show`).

2. From that page, obtain an existing embeddable host ID (records are loaded from
`EmbeddableHost.all.order(:host)` in
`app/controllers/admin/embedding_controller.rb:15-19`) and then delete that record in
another session or console (so the original ID becomes stale).

3. In the first browser session, attempt to update the now-deleted host by issuing `PUT
/admin/embeddable_hosts/:id.json` with the stale ID (route defined at
`config/routes.rb:42-43,153` mapping to `Admin::EmbeddableHostsController#update`).

4. The controller action at `app/controllers/admin/embeddable_hosts_controller.rb:9-12`
executes `host = EmbeddableHost.where(id: params[:id]).first`, which returns `nil` for the
stale ID, then calls `save_host(host)`, and inside `save_host` at
`app/controllers/admin/embeddable_hosts_controller.rb:22-25` the line `host.host = ...`
raises `NoMethodError` on `nil`, producing an unhandled 500 error instead of the 404
behavior implemented for `Discourse::NotFound` in
`app/controllers/application_controller.rb:108-110`.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** app/controllers/admin/embeddable_hosts_controller.rb
**Line:** 11:11
**Comment:**
	*Null Pointer: When updating a host, if the requested record ID does not exist, `EmbeddableHost.where(...).first` will return `nil` and `save_host(host)` will attempt to call methods on `nil`, causing a 500 error instead of a proper not-found response.

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 destroy
host = EmbeddableHost.where(id: params[:id]).first
host.destroy

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: When destroying a host, if the given ID does not exist, EmbeddableHost.where(...).first returns nil and host.destroy will raise an exception, producing a 500 error instead of a clean 404-style response. [null pointer]

Severity Level: Major ⚠️
- ❌ Admin delete host endpoint 500s when ID is stale.
- ⚠️ Admin embedding UI misbehaves deleting already-removed host.
Suggested change
host.destroy
raise Discourse::NotFound unless host
Steps of Reproduction ✅
1. Log in as a staff user and open the admin embedding UI at `GET
/admin/customize/embedding` (routed via `config/routes.rb:138-139` to
`Admin::EmbeddingController#show`, which lists `EmbeddableHost` records).

2. In one session or Rails console, delete a specific `EmbeddableHost` record directly so
that its ID becomes stale for any open admin page.

3. In another browser tab where the old list is still loaded, attempt to delete that stale
host by issuing `DELETE /admin/embeddable_hosts/:id.json` with the stale ID (resource
route defined at `config/routes.rb:42-43,153` to
`Admin::EmbeddableHostsController#destroy`).

4. The destroy action at `app/controllers/admin/embeddable_hosts_controller.rb:14-18` runs
`host = EmbeddableHost.where(id: params[:id]).first`, which returns `nil` for the
non-existent ID, then immediately calls `host.destroy`, raising `NoMethodError` on `nil`
and returning a 500 error instead of the `Discourse::NotFound`-driven 404 handled in
`app/controllers/application_controller.rb:108-110`.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** app/controllers/admin/embeddable_hosts_controller.rb
**Line:** 16:16
**Comment:**
	*Null Pointer: When destroying a host, if the given ID does not exist, `EmbeddableHost.where(...).first` returns `nil` and `host.destroy` will raise an exception, producing a 500 error instead of a clean 404-style response.

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.
👍 | 👎

render json: success_json
end

protected

def save_host(host)
host.host = params[:embeddable_host][:host]
host.category_id = params[:embeddable_host][:category_id]
host.category_id = SiteSetting.uncategorized_category_id if host.category_id.blank?

if host.save
render_serialized(host, EmbeddableHostSerializer, root: 'embeddable_host', rest_serializer: true)
else
render_json_error(host)
end
end

end
21 changes: 21 additions & 0 deletions app/controllers/admin/embedding_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
class Admin::EmbeddingController < Admin::AdminController

before_filter :ensure_logged_in, :ensure_staff, :fetch_embedding

def show
render_serialized(@embedding, EmbeddingSerializer, root: 'embedding', rest_serializer: true)
end

def update
render_serialized(@embedding, EmbeddingSerializer, root: 'embedding', rest_serializer: true)
end

protected

def fetch_embedding
@embedding = OpenStruct.new({
id: 'default',
embeddable_hosts: EmbeddableHost.all.order(:host)
})
end
end
3 changes: 1 addition & 2 deletions app/controllers/embed_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,7 @@ def count
def ensure_embeddable

if !(Rails.env.development? && current_user.try(:admin?))
raise Discourse::InvalidAccess.new('embeddable hosts not set') if SiteSetting.embeddable_hosts.blank?
raise Discourse::InvalidAccess.new('invalid referer host') unless SiteSetting.allows_embeddable_host?(request.referer)
raise Discourse::InvalidAccess.new('invalid referer host') unless EmbeddableHost.host_allowed?(request.referer)
end

response.headers['X-Frame-Options'] = "ALLOWALL"
Expand Down
24 changes: 24 additions & 0 deletions app/models/embeddable_host.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
class EmbeddableHost < ActiveRecord::Base
validates_format_of :host, :with => /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?\Z/i
belongs_to :category

before_validation do
self.host.sub!(/^https?:\/\//, '')
self.host.sub!(/\/.*$/, '')
end

def self.record_for_host(host)
uri = URI(host) rescue nil
return false unless uri.present?

host = uri.host
return false unless host.present?

Comment on lines +12 to +16

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: record_for_host currently uses only uri.host when looking up the embeddable host, ignoring any non-standard port; this means a host saved as example.com:3000 will never match a referer like http://example.com:3000/..., causing host_allowed? to return false and valid embeds on custom ports to be rejected. Include the port in the lookup key when present so records with ports can be found correctly. [logic error]

Severity Level: Critical 🚨
- ❌ Embeds from hosts on nonstandard ports always rejected.
- ⚠️ TopicRetriever invalid_host? misclassifies allowed port-specific hosts.
- ⚠️ New embedded topics miscategorized when using port-specific hosts.
Suggested change
return false unless uri.present?
host = uri.host
return false unless host.present?
return false unless uri && uri.host.present?
base_host = uri.host.downcase
host_with_port = if uri.port && uri.port != 80 && uri.port != 443
"#{base_host}:#{uri.port}"
else
base_host
end
where("lower(host) = ?", host_with_port).first
Steps of Reproduction ✅
1. Using `Admin::EmbeddableHostsController#create` in
`app/controllers/admin/embeddable_hosts_controller.rb:5-7`, create an `EmbeddableHost`
with `host` set to `"example.com:3000"`; `save_host` at lines 22-27 assigns this string
directly and the `before_validation` callback in `app/models/embeddable_host.rb:5-8`
leaves the `:3000` port intact.

2. Host a page at `http://example.com:3000/article` that uses Discourse embeds so that the
browser loads `EmbedController#comments` in `app/controllers/embed_controller.rb:7-35`
with `request.referer` equal to `"http://example.com:3000/article"`.

3. Before the action runs, the `ensure_embeddable` filter in
`app/controllers/embed_controller.rb:56-62` executes and calls
`EmbeddableHost.host_allowed?(request.referer)` (line 61), which delegates to
`EmbeddableHost.record_for_host` in `app/models/embeddable_host.rb:10-21`.

4. `record_for_host` parses the referer, sets `host = uri.host` (line 14, e.g.
`"example.com"` without `:3000`), and queries `where("lower(host) = ?", host).first` (line
17), which does not match the stored `"example.com:3000"` record; `host_allowed?` returns
`false` and `ensure_embeddable` raises `Discourse::InvalidAccess.new('invalid referer
host')`, so all embeds from `example.com:3000` are rejected.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** app/models/embeddable_host.rb
**Line:** 12:16
**Comment:**
	*Logic Error: `record_for_host` currently uses only `uri.host` when looking up the embeddable host, ignoring any non-standard port; this means a host saved as `example.com:3000` will never match a referer like `http://example.com:3000/...`, causing `host_allowed?` to return false and valid embeds on custom ports to be rejected. Include the port in the lookup key when present so records with ports can be found correctly.

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.
👍 | 👎

where("lower(host) = ?", host).first
end

def self.host_allowed?(host)
record_for_host(host).present?
end

end
14 changes: 0 additions & 14 deletions app/models/site_setting.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,20 +68,6 @@ def self.anonymous_menu_items
@anonymous_menu_items ||= Set.new Discourse.anonymous_filters.map(&:to_s)
end

def self.allows_embeddable_host?(host)
return false if embeddable_hosts.blank?
uri = URI(host) rescue nil
return false unless uri.present?

host = uri.host
return false unless host.present?

!!embeddable_hosts.split("\n").detect {|h| h.sub(/^https?\:\/\//, '') == host }

hosts = embeddable_hosts.split("\n").map {|h| (URI(h).host rescue nil) || h }
!!hosts.detect {|h| h == host}
end

def self.anonymous_homepage
top_menu_items.map { |item| item.name }
.select { |item| anonymous_menu_items.include?(item) }
Expand Down
2 changes: 1 addition & 1 deletion app/models/topic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -866,7 +866,7 @@ def has_topic_embed?
end

def expandable_first_post?
SiteSetting.embeddable_hosts.present? && SiteSetting.embed_truncate? && has_topic_embed?
SiteSetting.embed_truncate? && has_topic_embed?
end

TIME_TO_FIRST_RESPONSE_SQL ||= <<-SQL
Expand Down
4 changes: 3 additions & 1 deletion app/models/topic_embed.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ def self.import(user, url, title, contents)
# If there is no embed, create a topic, post and the embed.
if embed.blank?
Topic.transaction do
eh = EmbeddableHost.record_for_host(url)

creator = PostCreator.new(user,
title: title,
raw: absolutize_urls(url, contents),
skip_validations: true,
cook_method: Post.cook_methods[:raw_html],
category: SiteSetting.embed_category)
category: eh.try(:category_id))
post = creator.create
if post.present?
TopicEmbed.create!(topic_id: post.topic_id,
Expand Down
16 changes: 16 additions & 0 deletions app/serializers/embeddable_host_serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class EmbeddableHostSerializer < ApplicationSerializer
attributes :id, :host, :category_id

def id
object.id
end

def host
object.host
end

def category_id
object.category_id
end
end

8 changes: 8 additions & 0 deletions app/serializers/embedding_serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class EmbeddingSerializer < ApplicationSerializer
attributes :id
has_many :embeddable_hosts, serializer: EmbeddableHostSerializer, embed: :ids

def id
object.id
end
end
8 changes: 8 additions & 0 deletions config/locales/client.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2491,6 +2491,14 @@ en:
image: "Image"
delete_confirm: "Are you sure you want to delete the :%{name}: emoji?"

embedding:
confirm_delete: "Are you sure you want to delete that host?"
title: "Embedding"
host: "Allowed Hosts"
edit: "edit"
category: "Post to Category"
add_host: "Add Host"

permalink:
title: "Permalinks"
url: "URL"
Expand Down
2 changes: 0 additions & 2 deletions config/locales/server.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1164,13 +1164,11 @@ en:
autohighlight_all_code: "Force apply code highlighting to all preformatted code blocks even when they didn't explicitly specify the language."
highlighted_languages: "Included syntax highlighting rules. (Warning: including too many langauges may impact performance) see: https://highlightjs.org/static/demo/ for a demo"

embeddable_hosts: "Host(s) that can embed the comments from this Discourse forum. Hostname only, do not begin with http://"
feed_polling_enabled: "EMBEDDING ONLY: Whether to embed a RSS/ATOM feed as posts."
feed_polling_url: "EMBEDDING ONLY: URL of RSS/ATOM feed to embed."
embed_by_username: "Discourse username of the user who creates the embedded topics."
embed_username_key_from_feed: "Key to pull discourse username from feed."
embed_truncate: "Truncate the embedded posts."
embed_category: "Category of embedded topics."
embed_post_limit: "Maximum number of posts to embed."
embed_whitelist_selector: "CSS selector for elements that are allowed in embeds."
embed_blacklist_selector: "CSS selector for elements that are removed from embeds."
Expand Down
Loading