Skip to content

Commit 6a7653b

Browse files
authored
Merge pull request #1834 from codidact/0valt/csrf
Switch to cookie-based CSRF
2 parents 15ed78d + d3dd9de commit 6a7653b

12 files changed

Lines changed: 92 additions & 52 deletions

File tree

app/assets/javascripts/posts.js

Lines changed: 19 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ $(() => {
5757

5858
const $fileInput = $tgt.find('input[type="file"]');
5959
const files = /** @type {HTMLInputElement} */ ($fileInput[0]).files;
60+
const form = /** @type {HTMLFormElement} */ ($tgt[0]);
6061

6162
// TODO: MaxUploadSize is a site setting and can be changed
6263
if (files.length > 0 && files[0].size >= 2000000) {
@@ -81,40 +82,27 @@ $(() => {
8182
$tgt.find('.js-max-size').removeClass('has-color-red-700');
8283
}
8384

84-
const resp = await fetch($tgt.attr('action'), {
85-
method: $tgt.attr('method'),
86-
body: new FormData(/** @type {HTMLFormElement} */ ($tgt[0])),
87-
});
85+
const data = await QPixel.upload($tgt.attr('action'), form);
8886

89-
const data = await resp.json();
87+
QPixel.handleJSONResponse(
88+
data,
89+
(data) => {
90+
form.reset();
9091

91-
if (resp.status === 200) {
92-
$tgt.trigger('ajax:success', data);
93-
}
94-
else {
95-
$tgt.trigger('ajax:failure', data);
96-
}
97-
});
92+
const $postField = $('.js-post-field');
93+
const postText = $postField.val()?.toString();
94+
$postField.val(postText.replace(placeholder, `![Image_alt_text](${data.link})`));
95+
$tgt.parents('.modal').removeClass('is-active');
9896

99-
$uploadForm.on('ajax:success', async (evt, data) => {
100-
const $tgt = $(evt.target);
101-
/** @type {HTMLFormElement} */ ($tgt[0]).reset();
102-
103-
const $postField = $('.js-post-field');
104-
const postText = $postField.val()?.toString();
105-
$postField.val(postText.replace(placeholder, `![Image_alt_text](${data.link})`));
106-
$tgt.parents('.modal').removeClass('is-active');
107-
108-
$postFields.trigger('change');
109-
});
110-
111-
$uploadForm.on('ajax:failure', async (evt, data) => {
112-
const $tgt = $(evt.target);
113-
const $postField = $('.js-post-field');
114-
const error = data['error'];
115-
QPixel.createNotification('danger', error);
116-
$tgt.parents('.modal').removeClass('is-active');
117-
$postField.val($postField.val()?.toString().replace(placeholder, ''));
97+
$postFields.trigger('change');
98+
},
99+
(data) => {
100+
if (data.status === 'failed') {
101+
const $postField = $('.js-post-field');
102+
$postField.val($postField.val()?.toString().replace(placeholder, ''));
103+
}
104+
},
105+
);
118106
});
119107

120108
/**

app/assets/javascripts/qpixel_api.js

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,6 @@ const validators = [];
77
let popped_modals_ct = 0;
88

99
window.QPixel = {
10-
csrfToken: () => {
11-
const token = $('meta[name="csrf-token"]').attr('content');
12-
QPixel.csrfToken = () => token;
13-
return token;
14-
},
15-
1610
createNotification: function (type, message) {
1711
// Some messages include a date stamp, `append_date` governs that.
1812
let append_date = false;
@@ -320,10 +314,8 @@ window.QPixel = {
320314

321315
fetch: async (uri, init) => {
322316
const defaultHeaders = {
323-
'X-CSRF-Token': QPixel.csrfToken(),
324317
// X-Requested-With is necessary for request.xhr? to work
325318
'X-Requested-With': 'XMLHttpRequest',
326-
'Content-Type': 'application/json',
327319
};
328320

329321
const { headers = {}, ...restInit } = init ?? {};
@@ -342,11 +334,17 @@ window.QPixel = {
342334
},
343335

344336
fetchJSON: async (uri, data, options = {}) => {
337+
const { headers = {}, ...restOptions } = options
338+
345339
/** @type {RequestInit} */
346340
const requestInit = {
347341
method: 'POST',
348342
body: options.method === 'GET' ? void 0 : JSON.stringify(data),
349-
...options,
343+
headers: {
344+
'Content-Type': 'application/json',
345+
...headers,
346+
},
347+
...restOptions,
350348
};
351349

352350
return QPixel.fetch(uri, requestInit);
@@ -464,6 +462,15 @@ window.QPixel = {
464462
return QPixel.parseJSONResponse(resp, 'Failed to vote');
465463
},
466464

465+
upload: async (url, form) => {
466+
const resp = await QPixel.fetch(url, {
467+
method: 'POST',
468+
body: new FormData(form)
469+
});
470+
471+
return QPixel.parseJSONResponse(resp, 'Failed to upload');
472+
},
473+
467474
archiveThread: async (id) => {
468475
const resp = await QPixel.fetchJSON(`/comments/thread/${id}/archive`, {}, {
469476
headers: { 'Accept': 'application/json' },

app/controllers/application_controller.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
class ApplicationController < ActionController::Base
55
# Prevent CSRF attacks by raising an exception.
66
# For APIs, you may want to use :null_session instead.
7-
protect_from_forgery with: :exception
7+
protect_from_forgery with: :exception, store: :cookie
88
before_action :configure_permitted_parameters, if: :devise_controller?
99
before_action :set_globals
1010
before_action :enforce_signed_in, unless: :devise_controller?
@@ -417,4 +417,10 @@ def require_sudo
417417
redirect_to user_sudo_path
418418
end
419419
end
420+
421+
# default request_authenticity_tokens only checks form tokens and request.x_csrf_token
422+
# for some reason, even if cookie-based strategy is officially supported, it's not checked here
423+
def request_authenticity_tokens
424+
super << csrf_token_storage_strategy.fetch(request)
425+
end
420426
end

app/controllers/errors_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Provides web actions that represent errors. Rails' standard error pages are static HTML with inline CSS; by using
22
# a custom error controller we get all the layouts and CSS.
33
class ErrorsController < ApplicationController
4-
protect_from_forgery except: [:error]
4+
protect_from_forgery with: :exception, except: [:error], store: :cookie
55

66
def error
77
@exception = request.env['action_dispatch.exception']

app/controllers/posts_controller.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -510,14 +510,16 @@ def document
510510

511511
def upload
512512
unless helpers.valid_upload?(params[:file])
513-
render json: { error: "Images must be one of #{helpers.allowed_upload_extensions.join(', ')}" },
513+
render json: { status: 'failed',
514+
message: "Images must be one of #{helpers.allowed_upload_extensions.join(', ')}" },
514515
status: :bad_request
515516
return
516517
end
517518

518519
@blob = ActiveStorage::Blob.create_and_upload!(io: params[:file], filename: params[:file].original_filename,
519520
content_type: params[:file].content_type)
520-
render json: { link: uploaded_url(@blob.key) }
521+
render json: { status: 'success',
522+
link: uploaded_url(@blob.key) }
521523
end
522524

523525
def help_center

app/controllers/users/sessions_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
class Users::SessionsController < Devise::SessionsController
22
include Devise::Controllers::Rememberable
33

4-
protect_from_forgery except: [:create]
4+
protect_from_forgery with: :exception, except: [:create, :destroy], store: :cookie
55

66
mattr_accessor :first_factor, default: [], instance_writer: false, instance_reader: false
77

app/views/posts/_image_upload.html.erb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
</div>
88
<div class="modal--body">
99
<%= form_tag upload_path, multipart: true, class: 'form-inline upload-form js-upload-form' do %>
10-
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
1110
<div class="form-group">
1211
<%= label_tag :file, 'Insert an image', class: 'form-element' %>
1312
<div class="form-caption js-max-size">Max file size <%= SiteSetting['MaxUploadSize'] %>.</div>

config/environments/production.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
# Full error reports are disabled and caching is turned on.
1717
config.consider_all_requests_local = false
1818
config.action_controller.perform_caching = true
19+
Rack::MiniProfiler.config.disable_caching = false
1920

2021
# Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"]
2122
# or in config/master.key. This key is used to decrypt credentials (and other encrypted files).

global.d.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,10 @@ type QPixelResponseJSON<
213213
Success extends object = object
214214
> = (Success & QPixelSuccessResponseJSON) | QPixelFailedResponseJSON
215215

216+
type QPixelUploadResponseJSON = QPixelResponseJSON<{
217+
link: string
218+
}>
219+
216220
type QPixelVoteResponseJSON = QPixelResponseJSON<{
217221
vote_id: number
218222
upvotes: number
@@ -411,12 +415,6 @@ interface QPixel {
411415
*/
412416
addPrePostValidation?: (callback: PostValidator) => void;
413417

414-
/**
415-
* Get the current CSRF anti-forgery token. Should be passed as the X-CSRF-Token header when
416-
* making AJAX POST requests.
417-
*/
418-
csrfToken?: () => string;
419-
420418
/**
421419
* Create a notification popup - not an inbox notification.
422420
* @param type the type to apply to the popup - warning, danger, etc.
@@ -646,6 +644,12 @@ interface QPixel {
646644
*/
647645
vote?: (postId: string, voteType: string) => Promise<QPixelVoteResponseJSON>
648646

647+
/**
648+
* @param url upload endpoint URL (differs between routes)
649+
* @param form upload form to get the file from
650+
*/
651+
upload?: (url: string, form: HTMLFormElement) => Promise<QPixelUploadResponseJSON>
652+
649653
// qpixel_dom
650654
DOM?: QPixelDOM;
651655
// qpixel Markdown
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
require 'test_helper'
2+
3+
class PostsControllerTest < ActionController::TestCase
4+
include Devise::Test::ControllerHelpers
5+
6+
test 'upload should correctly check file types' do
7+
sign_in users(:standard_user)
8+
9+
try_upload_file('not_uploadable.json', 'application/json')
10+
11+
assert_response(:bad_request)
12+
assert_valid_json_response
13+
assert_not_nil JSON.parse(response.body)['message']
14+
15+
try_upload_file('uploadable_png.png', 'image/png')
16+
17+
assert_json_success
18+
assert_not_nil JSON.parse(response.body)['link']
19+
end
20+
21+
private
22+
23+
def try_upload_file(path, mime)
24+
post :upload, params: {
25+
file: file_fixture_upload(path, mime)
26+
}
27+
end
28+
end

0 commit comments

Comments
 (0)