mirror of
https://github.com/discourse/discourse.git
synced 2025-02-21 03:19:10 +00:00
SECURITY: Limit /inline-onebox to 10 URLs at a time
This commit is contained in:
parent
6d0173c9bd
commit
416ec83ae5
@ -0,0 +1,56 @@
|
||||
import { settled } from "@ember/test-helpers";
|
||||
import { setupTest } from "ember-qunit";
|
||||
import { applyInlineOneboxes } from "pretty-text/inline-oneboxer";
|
||||
import { module, test } from "qunit";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import pretender, { response } from "discourse/tests/helpers/create-pretender";
|
||||
|
||||
module("Unit | Pretty Text | Inline Oneboxer", function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
let links;
|
||||
hooks.beforeEach(function () {
|
||||
links = {};
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const url = `http://example.com/url-${i}`;
|
||||
links[url] = document.createElement("DIV");
|
||||
}
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
links = {};
|
||||
});
|
||||
|
||||
test("batches requests when oneboxing more than 10 urls", async function (assert) {
|
||||
const requestedUrls = [];
|
||||
let requestCount = 0;
|
||||
|
||||
pretender.get("/inline-onebox", async (request) => {
|
||||
requestCount++;
|
||||
requestedUrls.push(...request.queryParams.urls);
|
||||
return response(200, { "inline-oneboxes": [] });
|
||||
});
|
||||
|
||||
applyInlineOneboxes(links, ajax);
|
||||
await settled();
|
||||
|
||||
assert.strictEqual(
|
||||
requestCount,
|
||||
2,
|
||||
"it splits the 11 urls into 2 requests"
|
||||
);
|
||||
assert.deepEqual(requestedUrls, [
|
||||
"http://example.com/url-0",
|
||||
"http://example.com/url-1",
|
||||
"http://example.com/url-2",
|
||||
"http://example.com/url-3",
|
||||
"http://example.com/url-4",
|
||||
"http://example.com/url-5",
|
||||
"http://example.com/url-6",
|
||||
"http://example.com/url-7",
|
||||
"http://example.com/url-8",
|
||||
"http://example.com/url-9",
|
||||
"http://example.com/url-10",
|
||||
]);
|
||||
});
|
||||
});
|
@ -1,6 +1,6 @@
|
||||
const _cache = {};
|
||||
|
||||
export function applyInlineOneboxes(inline, ajax, opts) {
|
||||
export async function applyInlineOneboxes(inline, ajax, opts) {
|
||||
opts = opts || {};
|
||||
|
||||
const urls = Object.keys(inline).filter((url) => !_cache[url]);
|
||||
@ -14,26 +14,35 @@ export function applyInlineOneboxes(inline, ajax, opts) {
|
||||
return;
|
||||
}
|
||||
|
||||
ajax("/inline-onebox", {
|
||||
data: {
|
||||
urls,
|
||||
category_id: opts.categoryId,
|
||||
topic_id: opts.topicId,
|
||||
},
|
||||
}).then((result) => {
|
||||
result["inline-oneboxes"].forEach((onebox) => {
|
||||
if (onebox.title) {
|
||||
_cache[onebox.url] = onebox;
|
||||
const batchSize = 10;
|
||||
for (let i = 0; i < urls.length; i += batchSize) {
|
||||
const batch = urls.slice(i, i + batchSize);
|
||||
|
||||
let links = inline[onebox.url] || [];
|
||||
links.forEach((link) => {
|
||||
link.innerText = onebox.title;
|
||||
link.classList.add("inline-onebox");
|
||||
link.classList.remove("inline-onebox-loading");
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
try {
|
||||
const result = await ajax("/inline-onebox", {
|
||||
data: {
|
||||
urls: batch,
|
||||
category_id: opts.categoryId,
|
||||
topic_id: opts.topicId,
|
||||
},
|
||||
});
|
||||
result["inline-oneboxes"].forEach((onebox) => {
|
||||
if (onebox.title) {
|
||||
_cache[onebox.url] = onebox;
|
||||
|
||||
let links = inline[onebox.url] || [];
|
||||
links.forEach((link) => {
|
||||
link.innerText = onebox.title;
|
||||
link.classList.add("inline-onebox");
|
||||
link.classList.remove("inline-onebox-loading");
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Inline onebox request failed", err, batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function cachedInlineOnebox(url) {
|
||||
|
@ -1,10 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class InlineOneboxController < ApplicationController
|
||||
MAX_URLS_LIMIT = 10
|
||||
|
||||
requires_login
|
||||
|
||||
def show
|
||||
urls = params[:urls] || []
|
||||
|
||||
if urls.size > MAX_URLS_LIMIT
|
||||
render json: failed_json.merge(errors: [I18n.t("inline_oneboxer.too_many_urls")]), status: 413
|
||||
return
|
||||
end
|
||||
|
||||
current_user_id = current_user.id
|
||||
|
||||
if InlineOneboxer.is_previewing?(current_user_id)
|
||||
response.headers["Retry-After"] = "60"
|
||||
render json: failed_json.merge(errors: [I18n.t("inline_oneboxer.concurrency_not_allowed")]),
|
||||
status: 429
|
||||
return
|
||||
end
|
||||
|
||||
hijack do
|
||||
InlineOneboxer.preview!(current_user_id)
|
||||
|
||||
oneboxes =
|
||||
InlineOneboxer.new(
|
||||
params[:urls] || [],
|
||||
@ -12,6 +32,8 @@ class InlineOneboxController < ApplicationController
|
||||
category_id: params[:category_id].to_i,
|
||||
topic_id: params[:topic_id].to_i,
|
||||
).process
|
||||
|
||||
InlineOneboxer.finish_preview!(current_user_id)
|
||||
render json: { "inline-oneboxes" => oneboxes }
|
||||
end
|
||||
end
|
||||
|
@ -70,6 +70,8 @@ en:
|
||||
inline_oneboxer:
|
||||
topic_page_title_post_number: "#%{post_number}"
|
||||
topic_page_title_post_number_by_user: "#%{post_number} by %{username}"
|
||||
too_many_urls: "Can't inline onebox more than 10 URLs in a single request."
|
||||
concurrency_not_allowed: "Concurrent inline-oneboxing requests are not allowed. Please send one request at a time."
|
||||
components:
|
||||
enabled_filter: "Enabled"
|
||||
disabled_filter: "Disabled"
|
||||
|
@ -89,6 +89,18 @@ class InlineOneboxer
|
||||
nil
|
||||
end
|
||||
|
||||
def self.is_previewing?(user_id)
|
||||
Discourse.redis.get(preview_key(user_id)) == "1"
|
||||
end
|
||||
|
||||
def self.preview!(user_id)
|
||||
Discourse.redis.setex(preview_key(user_id), 1.minute, "1")
|
||||
end
|
||||
|
||||
def self.finish_preview!(user_id)
|
||||
Discourse.redis.del(preview_key(user_id))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.onebox_for(url, title, opts)
|
||||
@ -129,4 +141,8 @@ class InlineOneboxer
|
||||
author.username
|
||||
end
|
||||
end
|
||||
|
||||
def self.preview_key(user_id)
|
||||
"inline-onebox:preview:#{user_id}"
|
||||
end
|
||||
end
|
||||
|
@ -7,7 +7,8 @@ RSpec.describe InlineOneboxController do
|
||||
end
|
||||
|
||||
context "when logged in" do
|
||||
let!(:user) { sign_in(Fabricate(:user)) }
|
||||
fab!(:user)
|
||||
before { sign_in(user) }
|
||||
|
||||
it "returns empty JSON for empty input" do
|
||||
get "/inline-onebox.json", params: { urls: [] }
|
||||
@ -16,6 +17,107 @@ RSpec.describe InlineOneboxController do
|
||||
expect(json["inline-oneboxes"]).to eq([])
|
||||
end
|
||||
|
||||
it "returns a 413 error if more than 10 urls are sent" do
|
||||
get "/inline-onebox.json", params: { urls: ("a".."k").to_a }
|
||||
expect(response.status).to eq(413)
|
||||
json = response.parsed_body
|
||||
expect(json["errors"]).to include(I18n.t("inline_oneboxer.too_many_urls"))
|
||||
end
|
||||
|
||||
it "returns a 429 error for concurrent requests from the same user" do
|
||||
blocked = true
|
||||
reached = false
|
||||
|
||||
stub_request(:get, "http://example.com/url-1").to_return do |request|
|
||||
reached = true
|
||||
sleep 0.001 while blocked
|
||||
{ status: 200, body: <<~HTML }
|
||||
<html>
|
||||
<head>
|
||||
<title>
|
||||
Concurrent inline-oneboxing test
|
||||
</title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
HTML
|
||||
end
|
||||
|
||||
t1 = Thread.new { get "/inline-onebox.json", params: { urls: ["http://example.com/url-1"] } }
|
||||
|
||||
sleep 0.001 while !reached
|
||||
|
||||
get "/inline-onebox.json", params: { urls: ["http://example.com/url-2"] }
|
||||
expect(response.status).to eq(429)
|
||||
expect(response.parsed_body["errors"]).to include(
|
||||
I18n.t("inline_oneboxer.concurrency_not_allowed"),
|
||||
)
|
||||
|
||||
blocked = false
|
||||
t1.join
|
||||
expect(response.status).to eq(200)
|
||||
json = response.parsed_body
|
||||
expect(json["inline-oneboxes"].size).to eq(1)
|
||||
expect(json["inline-oneboxes"][0]["title"]).to eq("Concurrent inline-oneboxing test")
|
||||
end
|
||||
|
||||
it "allows concurrent requests from different users" do
|
||||
another_user = Fabricate(:user)
|
||||
|
||||
blocked = true
|
||||
reached = false
|
||||
|
||||
stub_request(:get, "http://example.com/url-1").to_return do |request|
|
||||
reached = true
|
||||
sleep 0.001 while blocked
|
||||
{ status: 200, body: <<~HTML }
|
||||
<html>
|
||||
<head>
|
||||
<title>
|
||||
Concurrent inline-oneboxing test
|
||||
</title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
HTML
|
||||
end
|
||||
|
||||
stub_request(:get, "http://example.com/url-2").to_return do |request|
|
||||
{ status: 200, body: <<~HTML }
|
||||
<html>
|
||||
<head>
|
||||
<title>
|
||||
Concurrent inline-oneboxing test 2
|
||||
</title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
HTML
|
||||
end
|
||||
|
||||
t1 =
|
||||
Thread.new do
|
||||
sign_in(user)
|
||||
get "/inline-onebox.json", params: { urls: ["http://example.com/url-1"] }
|
||||
end
|
||||
|
||||
sleep 0.001 while !reached
|
||||
|
||||
sign_in(another_user)
|
||||
get "/inline-onebox.json", params: { urls: ["http://example.com/url-2"] }
|
||||
expect(response.status).to eq(200)
|
||||
json = response.parsed_body
|
||||
expect(json["inline-oneboxes"].size).to eq(1)
|
||||
expect(json["inline-oneboxes"][0]["title"]).to eq("Concurrent inline-oneboxing test 2")
|
||||
|
||||
blocked = false
|
||||
t1.join
|
||||
expect(response.status).to eq(200)
|
||||
json = response.parsed_body
|
||||
expect(json["inline-oneboxes"].size).to eq(1)
|
||||
expect(json["inline-oneboxes"][0]["title"]).to eq("Concurrent inline-oneboxing test")
|
||||
end
|
||||
|
||||
context "with topic link" do
|
||||
fab!(:topic)
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user