Skip to content

Commit a5a66e7

Browse files
authored
Merge pull request #463 from basecamp/security/revision-and-upload
Authorize revision history and markdown uploads against current book access
2 parents b1d0834 + b4e74b3 commit a5a66e7

8 files changed

Lines changed: 251 additions & 11 deletions

File tree

app/controllers/action_text/markdown/uploads_controller.rb

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ class ActionText::Markdown::UploadsController < ApplicationController
55
ActiveStorage::Current.url_options = { protocol: request.protocol, host: request.host, port: request.port }
66
end
77

8-
def create
9-
@record = GlobalID::Locator.locate_signed params[:record_gid]
8+
before_action :set_record, :ensure_editable, only: :create
9+
before_action :set_attachment, :ensure_attachment_readable, only: :show
1010

11+
def create
1112
@markdown = @record.safe_markdown_attribute params[:attribute_name]
1213
@markdown.uploads.attach [ params[:file] ]
1314
@markdown.save!
@@ -18,8 +19,40 @@ def create
1819
end
1920

2021
def show
21-
@attachment = ActiveStorage::Attachment.find_by! slug: "#{params[:slug]}.#{params[:format]}"
22-
expires_in 1.year, public: true
22+
if @book&.published?
23+
expires_in 1.year, public: true
24+
else
25+
expires_in 5.minutes, public: false
26+
end
27+
2328
redirect_to @attachment.url
2429
end
30+
31+
private
32+
# The signed id rendered into the page editor says who could upload when it was
33+
# minted, not who may upload now. Resolve the book it belongs to and authorize
34+
# against that, so revoking access takes effect here like it does everywhere else.
35+
def set_record
36+
@record = GlobalID::Locator.locate_signed params[:record_gid],
37+
only: Page, for: ActionText::Markdown::UPLOADS_SIGNED_ID_PURPOSE
38+
@book = Book.accessable_or_published.find_by(id: @record&.owning_book&.id)
39+
40+
head :not_found unless @book
41+
end
42+
43+
def ensure_editable
44+
head :forbidden unless @book.editable?
45+
end
46+
47+
def set_attachment
48+
@attachment = ActiveStorage::Attachment.find_by! slug: "#{params[:slug]}.#{params[:format]}"
49+
@book = @attachment.record.try(:record).try(:owning_book)
50+
end
51+
52+
# An unpublished book's uploads are as private as the book itself. Serving them to
53+
# anyone holding the URL made this a way to read them without an access row, and
54+
# caching them publicly for a year put them in shared caches besides.
55+
def ensure_attachment_readable
56+
head :not_found unless @book.nil? || @book.published? || @book.accessable?
57+
end
2558
end

app/controllers/concerns/page_leaf_scoped.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ module PageLeafScoped extend ActiveSupport::Concern
44
end
55

66
private
7+
# Scoped to active leaves, like SetBookLeaf. Trashing a page records an edit that
8+
# keeps the old body, so without this a trashed page's whole content stayed readable
9+
# here even though the page itself 404s on its own URL.
710
def set_leaf
8-
@leaf = Current.user.leaves.find(params[:page_id])
11+
@leaf = Current.user.leaves.active.find(params[:page_id])
912
end
1013
end

app/controllers/pages/edits_controller.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
class Pages::EditsController < ApplicationController
22
include PageLeafScoped
33

4+
before_action :ensure_editable
45
before_action :set_edit
56

67
def show
78
end
89

910
private
11+
# The only link to this screen lives in the page editor, which is already editor-gated,
12+
# so the interface has always expressed this restriction and the controller simply
13+
# didn't enforce it. Revision history shows text an editor removed from a page, which
14+
# a reader can't otherwise see even when the page itself is still readable.
15+
def ensure_editable
16+
head :forbidden unless @leaf.book.editable?
17+
end
18+
1019
def set_edit
1120
if params[:id] == "latest"
1221
@edit = @leaf.edits.last

app/models/leafable.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,18 @@ module Leafable
66
included do
77
has_one :leaf, as: :leafable, inverse_of: :leafable, touch: true
88
has_one :book, through: :leaf
9+
has_one :edit, as: :leafable
910

1011
delegate :title, to: :leaf
1112
end
1213

14+
# Editing a page supersedes its leafable: the leaf moves on to a copy and the original
15+
# is kept by the edit that records the revision, so it no longer has a leaf of its own.
16+
# Its uploads are still served, so the book has to stay findable through the edit.
17+
def owning_book
18+
book || edit&.leaf&.book
19+
end
20+
1321
def searchable_content
1422
nil
1523
end

lib/rails_ext/action_text_markdown.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
module ActionText
22
class Markdown < Record
3+
# The signed id that authorizes an upload is rendered into the page editor. Binding
4+
# it to a purpose keeps it from being used anywhere else a signed global id is
5+
# accepted, and expiring it bounds how long a copy taken off the page stays good.
6+
# Neither replaces the authorization check in the uploads controller.
7+
UPLOADS_SIGNED_ID_PURPOSE = :markdown_uploads
8+
UPLOADS_SIGNED_ID_EXPIRY = 1.day
9+
310
DEFAULT_RENDERER_OPTIONS = {
411
filter_html: false
512
}

lib/rails_ext/action_text_tag_helper.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,18 @@ def markdown_area(record, name, value: nil, **options)
66

77
data = options.delete(:data) || {}
88
data.reverse_merge! \
9-
uploads_url: action_text_markdown_uploads_url(record_gid: record.to_signed_global_id.to_s, attribute_name: name, format: "json")
9+
uploads_url: action_text_markdown_uploads_url(record_gid: uploads_signed_id_for(record), attribute_name: name, format: "json")
1010

1111
tag.house_md value, name: field_name, data: data, **options
1212
end
1313

14+
def uploads_signed_id_for(record)
15+
record.to_signed_global_id(
16+
expires_in: ActionText::Markdown::UPLOADS_SIGNED_ID_EXPIRY,
17+
for: ActionText::Markdown::UPLOADS_SIGNED_ID_PURPOSE
18+
).to_s
19+
end
20+
1421
def house_toolbar(**options, &block)
1522
tag.house_md_toolbar(**options, &block)
1623
end

test/controllers/action_text/markdown/uploads_controller_test.rb

Lines changed: 129 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class ActionText::Markdown::UploadsControllerTest < ActionDispatch::IntegrationT
88
test "attach a file" do
99
assert_changes -> { ActiveStorage::Attachment.count }, 1 do
1010
post action_text_markdown_uploads_url, params: {
11-
record_gid: pages(:welcome).to_signed_global_id.to_s,
11+
record_gid: uploads_signed_id_for(pages(:welcome)),
1212
attribute_name: "body",
1313
file: fixture_file_upload("reading.webp", "image/webp")
1414
}, as: :xhr
@@ -20,15 +20,139 @@ class ActionText::Markdown::UploadsControllerTest < ActionDispatch::IntegrationT
2020
assert JSON.parse(response.body)["fileUrl"].start_with?("/")
2121
end
2222

23-
test "view attached file" do
24-
markdown = pages(:welcome).body.tap(&:save!)
25-
markdown.uploads.attach fixture_file_upload("reading.webp", "image/webp")
23+
test "a signed id minted for some other purpose can't be used to upload" do
24+
assert_no_changes -> { ActiveStorage::Attachment.count } do
25+
post action_text_markdown_uploads_url, params: {
26+
record_gid: pages(:welcome).to_signed_global_id.to_s,
27+
attribute_name: "body",
28+
file: fixture_file_upload("reading.webp", "image/webp")
29+
}, as: :xhr
30+
end
31+
32+
assert_response :not_found
33+
end
34+
35+
test "an expired signed id can't be used to upload" do
36+
record_gid = uploads_signed_id_for(pages(:welcome))
37+
38+
travel ActionText::Markdown::UPLOADS_SIGNED_ID_EXPIRY + 1.hour do
39+
assert_no_changes -> { ActiveStorage::Attachment.count } do
40+
post action_text_markdown_uploads_url, params: {
41+
record_gid: record_gid,
42+
attribute_name: "body",
43+
file: fixture_file_upload("reading.webp", "image/webp")
44+
}, as: :xhr
45+
end
46+
end
47+
48+
assert_response :not_found
49+
end
50+
51+
test "a revoked collaborator can't upload with a signed id minted while an editor" do
52+
record_gid = uploads_signed_id_for(pages(:welcome))
53+
accesses(:kevin_handbook).destroy!
54+
55+
assert_no_changes -> { ActiveStorage::Attachment.count } do
56+
post action_text_markdown_uploads_url, params: {
57+
record_gid: record_gid,
58+
attribute_name: "body",
59+
file: fixture_file_upload("reading.webp", "image/webp")
60+
}, as: :xhr
61+
end
62+
63+
assert_response :not_found
64+
end
65+
66+
test "a downgraded editor can't upload" do
67+
record_gid = uploads_signed_id_for(pages(:welcome))
68+
accesses(:kevin_handbook).update! level: :reader
69+
70+
assert_no_changes -> { ActiveStorage::Attachment.count } do
71+
post action_text_markdown_uploads_url, params: {
72+
record_gid: record_gid,
73+
attribute_name: "body",
74+
file: fixture_file_upload("reading.webp", "image/webp")
75+
}, as: :xhr
76+
end
77+
78+
assert_response :forbidden
79+
end
80+
81+
test "a reader can't upload" do
82+
sign_in :jz
2683

27-
attachment = pages(:welcome).body.uploads.last
84+
assert_no_changes -> { ActiveStorage::Attachment.count } do
85+
post action_text_markdown_uploads_url, params: {
86+
record_gid: uploads_signed_id_for(pages(:welcome)),
87+
attribute_name: "body",
88+
file: fixture_file_upload("reading.webp", "image/webp")
89+
}, as: :xhr
90+
end
91+
92+
assert_response :forbidden
93+
end
94+
95+
test "view attached file" do
96+
books(:handbook).update! published: true
97+
attachment = attach_upload_to_welcome_page
2898

2999
get action_text_markdown_upload_url(slug: attachment.slug)
30100

31101
assert_response :redirect
32102
assert_match /\/rails\/active_storage\/.*\/reading\.webp/, @response.redirect_url
33103
end
104+
105+
test "an attachment of a published book is publicly cacheable" do
106+
books(:handbook).update! published: true
107+
attachment = attach_upload_to_welcome_page
108+
109+
get action_text_markdown_upload_url(slug: attachment.slug)
110+
111+
assert_match "public", @response.headers["Cache-Control"]
112+
end
113+
114+
test "an attachment of an unpublished book is not served to anonymous clients" do
115+
books(:handbook).update! published: false
116+
attachment = attach_upload_to_welcome_page
117+
118+
reset!
119+
get action_text_markdown_upload_url(slug: attachment.slug)
120+
121+
assert_response :not_found
122+
end
123+
124+
test "an attachment of an unpublished book is not served to a user without access" do
125+
books(:handbook).update! published: false
126+
attachment = attach_upload_to_welcome_page
127+
128+
accesses(:kevin_handbook).destroy!
129+
get action_text_markdown_upload_url(slug: attachment.slug)
130+
131+
assert_response :not_found
132+
end
133+
134+
test "an attachment of an unpublished book is served to a reader, but not publicly cached" do
135+
books(:handbook).update! published: false
136+
attachment = attach_upload_to_welcome_page
137+
138+
sign_in :jz
139+
get action_text_markdown_upload_url(slug: attachment.slug)
140+
141+
assert_response :redirect
142+
assert_no_match "public", @response.headers["Cache-Control"].to_s
143+
end
144+
145+
private
146+
def uploads_signed_id_for(record)
147+
record.to_signed_global_id(
148+
expires_in: ActionText::Markdown::UPLOADS_SIGNED_ID_EXPIRY,
149+
for: ActionText::Markdown::UPLOADS_SIGNED_ID_PURPOSE
150+
).to_s
151+
end
152+
153+
def attach_upload_to_welcome_page
154+
markdown = pages(:welcome).body.tap(&:save!)
155+
markdown.uploads.attach fixture_file_upload("reading.webp", "image/webp")
156+
pages(:welcome).body.uploads.last
157+
end
34158
end

test/controllers/pages/edits_controller_test.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,55 @@ class Pages::EditsControllerTest < ActionDispatch::IntegrationTest
3535
assert_no_match(/onerror/, response.body)
3636
end
3737

38+
test "a trashed page's history is not reachable" do
39+
leaf = leaves(:welcome_page)
40+
leaf.edit leafable_params: { body: "Embargoed announcement: Project X" }
41+
leaf.trashed!
42+
43+
get page_edit_url(leaf, "latest")
44+
45+
assert_response :not_found
46+
end
47+
48+
test "a trashed page's history is not reachable by a reader either" do
49+
leaf = leaves(:welcome_page)
50+
leaf.edit leafable_params: { body: "Embargoed announcement: Project X" }
51+
leaf.trashed!
52+
53+
sign_in :jz
54+
get page_edit_url(leaf, "latest")
55+
56+
assert_response :not_found
57+
end
58+
59+
test "a reader can't read a live page's history" do
60+
leaves(:welcome_page).edit leafable_params: { body: "Updated" }
61+
62+
sign_in :jz
63+
get page_edit_url(leaves(:welcome_page), "latest")
64+
65+
assert_response :forbidden
66+
end
67+
68+
test "an editor can still read a live page's history" do
69+
leaves(:welcome_page).edit leafable_params: { body: "Updated" }
70+
71+
get page_edit_url(leaves(:welcome_page), "latest")
72+
73+
assert_response :success
74+
end
75+
76+
test "a user with no access to the book gets nothing" do
77+
leaf = leaves(:welcome_page)
78+
leaf.edit leafable_params: { body: "Updated" }
79+
accesses(:jz_handbook).destroy!
80+
81+
sign_in :jz
82+
get page_edit_url(leaf, "latest")
83+
84+
assert_response :not_found
85+
end
86+
3887
test "show sanitizes dangerous content in current version" do
3988
leaves(:welcome_page).edit leafable_params: { body: %(<img src=x onerror="alert(1)">) }
4089

0 commit comments

Comments
 (0)