active_storage_encryption 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Appraisals +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +236 -0
- data/Rakefile +17 -0
- data/bin/rails +26 -0
- data/bin/rubocop +8 -0
- data/config/initializers/active_storage_encryption.rb +9 -0
- data/config/routes.rb +7 -0
- data/gemfiles/rails_7.gemfile +7 -0
- data/gemfiles/rails_7.gemfile.lock +276 -0
- data/gemfiles/rails_8.gemfile +7 -0
- data/gemfiles/rails_8.gemfile.lock +276 -0
- data/lib/active_storage/service/encrypted_disk_service.rb +10 -0
- data/lib/active_storage/service/encrypted_mirror_service.rb +10 -0
- data/lib/active_storage/service/encrypted_s3_service.rb +10 -0
- data/lib/active_storage_encryption/encrypted_blobs_controller.rb +163 -0
- data/lib/active_storage_encryption/encrypted_disk_service/v1_scheme.rb +28 -0
- data/lib/active_storage_encryption/encrypted_disk_service/v2_scheme.rb +51 -0
- data/lib/active_storage_encryption/encrypted_disk_service.rb +186 -0
- data/lib/active_storage_encryption/encrypted_mirror_service.rb +76 -0
- data/lib/active_storage_encryption/encrypted_s3_service.rb +236 -0
- data/lib/active_storage_encryption/engine.rb +7 -0
- data/lib/active_storage_encryption/overrides.rb +201 -0
- data/lib/active_storage_encryption/private_url_policy.rb +53 -0
- data/lib/active_storage_encryption/resumable_gcs_upload.rb +194 -0
- data/lib/active_storage_encryption/version.rb +5 -0
- data/lib/active_storage_encryption.rb +79 -0
- data/lib/tasks/active_storage_encryption_tasks.rake +6 -0
- data/test/active_storage_encryption_test.rb +9 -0
- data/test/dummy/Rakefile +8 -0
- data/test/dummy/app/assets/stylesheets/application.css +1 -0
- data/test/dummy/app/controllers/application_controller.rb +6 -0
- data/test/dummy/app/helpers/application_helper.rb +4 -0
- data/test/dummy/app/models/application_record.rb +5 -0
- data/test/dummy/app/views/layouts/application.html.erb +22 -0
- data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
- data/test/dummy/app/views/pwa/service-worker.js +26 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/bin/setup +37 -0
- data/test/dummy/config/application.rb +43 -0
- data/test/dummy/config/boot.rb +7 -0
- data/test/dummy/config/credentials.yml.enc +1 -0
- data/test/dummy/config/database.yml +32 -0
- data/test/dummy/config/environment.rb +7 -0
- data/test/dummy/config/environments/development.rb +59 -0
- data/test/dummy/config/environments/production.rb +81 -0
- data/test/dummy/config/environments/test.rb +53 -0
- data/test/dummy/config/initializers/content_security_policy.rb +27 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +10 -0
- data/test/dummy/config/initializers/inflections.rb +18 -0
- data/test/dummy/config/initializers/permissions_policy.rb +15 -0
- data/test/dummy/config/locales/en.yml +31 -0
- data/test/dummy/config/master.key +1 -0
- data/test/dummy/config/puma.rb +36 -0
- data/test/dummy/config/routes.rb +5 -0
- data/test/dummy/config/storage.yml +21 -0
- data/test/dummy/config.ru +8 -0
- data/test/dummy/db/migrate/20250304023851_create_active_storage_tables.active_storage.rb +60 -0
- data/test/dummy/db/migrate/20250304023853_add_blob_encryption_key_column.rb +7 -0
- data/test/dummy/db/schema.rb +47 -0
- data/test/dummy/log/test.log +1022 -0
- data/test/dummy/public/404.html +67 -0
- data/test/dummy/public/406-unsupported-browser.html +66 -0
- data/test/dummy/public/422.html +67 -0
- data/test/dummy/public/500.html +66 -0
- data/test/dummy/public/icon.png +0 -0
- data/test/dummy/public/icon.svg +3 -0
- data/test/dummy/storage/test.sqlite3 +0 -0
- data/test/dummy/storage/x6/pl/x6plznfuhrsyjn9pox2a6xgmcs3x +0 -0
- data/test/dummy/storage/yq/sv/yqsvw5a72b3fv719zq8a6yb7lv0j +0 -0
- data/test/integration/encrypted_blobs_controller_test.rb +400 -0
- data/test/lib/encrypted_disk_service_test.rb +387 -0
- data/test/lib/encrypted_mirror_service_test.rb +159 -0
- data/test/lib/encrypted_s3_service_test.rb +293 -0
- data/test/test_helper.rb +19 -0
- metadata +264 -0
@@ -0,0 +1,67 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>The page you were looking for doesn't exist (404)</title>
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
6
|
+
<style>
|
7
|
+
.rails-default-error-page {
|
8
|
+
background-color: #EFEFEF;
|
9
|
+
color: #2E2F30;
|
10
|
+
text-align: center;
|
11
|
+
font-family: arial, sans-serif;
|
12
|
+
margin: 0;
|
13
|
+
}
|
14
|
+
|
15
|
+
.rails-default-error-page div.dialog {
|
16
|
+
width: 95%;
|
17
|
+
max-width: 33em;
|
18
|
+
margin: 4em auto 0;
|
19
|
+
}
|
20
|
+
|
21
|
+
.rails-default-error-page div.dialog > div {
|
22
|
+
border: 1px solid #CCC;
|
23
|
+
border-right-color: #999;
|
24
|
+
border-left-color: #999;
|
25
|
+
border-bottom-color: #BBB;
|
26
|
+
border-top: #B00100 solid 4px;
|
27
|
+
border-top-left-radius: 9px;
|
28
|
+
border-top-right-radius: 9px;
|
29
|
+
background-color: white;
|
30
|
+
padding: 7px 12% 0;
|
31
|
+
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
32
|
+
}
|
33
|
+
|
34
|
+
.rails-default-error-page h1 {
|
35
|
+
font-size: 100%;
|
36
|
+
color: #730E15;
|
37
|
+
line-height: 1.5em;
|
38
|
+
}
|
39
|
+
|
40
|
+
.rails-default-error-page div.dialog > p {
|
41
|
+
margin: 0 0 1em;
|
42
|
+
padding: 1em;
|
43
|
+
background-color: #F7F7F7;
|
44
|
+
border: 1px solid #CCC;
|
45
|
+
border-right-color: #999;
|
46
|
+
border-left-color: #999;
|
47
|
+
border-bottom-color: #999;
|
48
|
+
border-bottom-left-radius: 4px;
|
49
|
+
border-bottom-right-radius: 4px;
|
50
|
+
border-top-color: #DADADA;
|
51
|
+
color: #666;
|
52
|
+
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
53
|
+
}
|
54
|
+
</style>
|
55
|
+
</head>
|
56
|
+
|
57
|
+
<body class="rails-default-error-page">
|
58
|
+
<!-- This file lives in public/404.html -->
|
59
|
+
<div class="dialog">
|
60
|
+
<div>
|
61
|
+
<h1>The page you were looking for doesn't exist.</h1>
|
62
|
+
<p>You may have mistyped the address or the page may have moved.</p>
|
63
|
+
</div>
|
64
|
+
<p>If you are the application owner check the logs for more information.</p>
|
65
|
+
</div>
|
66
|
+
</body>
|
67
|
+
</html>
|
@@ -0,0 +1,66 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>Your browser is not supported (406)</title>
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
6
|
+
<style>
|
7
|
+
.rails-default-error-page {
|
8
|
+
background-color: #EFEFEF;
|
9
|
+
color: #2E2F30;
|
10
|
+
text-align: center;
|
11
|
+
font-family: arial, sans-serif;
|
12
|
+
margin: 0;
|
13
|
+
}
|
14
|
+
|
15
|
+
.rails-default-error-page div.dialog {
|
16
|
+
width: 95%;
|
17
|
+
max-width: 33em;
|
18
|
+
margin: 4em auto 0;
|
19
|
+
}
|
20
|
+
|
21
|
+
.rails-default-error-page div.dialog > div {
|
22
|
+
border: 1px solid #CCC;
|
23
|
+
border-right-color: #999;
|
24
|
+
border-left-color: #999;
|
25
|
+
border-bottom-color: #BBB;
|
26
|
+
border-top: #B00100 solid 4px;
|
27
|
+
border-top-left-radius: 9px;
|
28
|
+
border-top-right-radius: 9px;
|
29
|
+
background-color: white;
|
30
|
+
padding: 7px 12% 0;
|
31
|
+
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
32
|
+
}
|
33
|
+
|
34
|
+
.rails-default-error-page h1 {
|
35
|
+
font-size: 100%;
|
36
|
+
color: #730E15;
|
37
|
+
line-height: 1.5em;
|
38
|
+
}
|
39
|
+
|
40
|
+
.rails-default-error-page div.dialog > p {
|
41
|
+
margin: 0 0 1em;
|
42
|
+
padding: 1em;
|
43
|
+
background-color: #F7F7F7;
|
44
|
+
border: 1px solid #CCC;
|
45
|
+
border-right-color: #999;
|
46
|
+
border-left-color: #999;
|
47
|
+
border-bottom-color: #999;
|
48
|
+
border-bottom-left-radius: 4px;
|
49
|
+
border-bottom-right-radius: 4px;
|
50
|
+
border-top-color: #DADADA;
|
51
|
+
color: #666;
|
52
|
+
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
53
|
+
}
|
54
|
+
</style>
|
55
|
+
</head>
|
56
|
+
|
57
|
+
<body class="rails-default-error-page">
|
58
|
+
<!-- This file lives in public/406-unsupported-browser.html -->
|
59
|
+
<div class="dialog">
|
60
|
+
<div>
|
61
|
+
<h1>Your browser is not supported.</h1>
|
62
|
+
<p>Please upgrade your browser to continue.</p>
|
63
|
+
</div>
|
64
|
+
</div>
|
65
|
+
</body>
|
66
|
+
</html>
|
@@ -0,0 +1,67 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>The change you wanted was rejected (422)</title>
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
6
|
+
<style>
|
7
|
+
.rails-default-error-page {
|
8
|
+
background-color: #EFEFEF;
|
9
|
+
color: #2E2F30;
|
10
|
+
text-align: center;
|
11
|
+
font-family: arial, sans-serif;
|
12
|
+
margin: 0;
|
13
|
+
}
|
14
|
+
|
15
|
+
.rails-default-error-page div.dialog {
|
16
|
+
width: 95%;
|
17
|
+
max-width: 33em;
|
18
|
+
margin: 4em auto 0;
|
19
|
+
}
|
20
|
+
|
21
|
+
.rails-default-error-page div.dialog > div {
|
22
|
+
border: 1px solid #CCC;
|
23
|
+
border-right-color: #999;
|
24
|
+
border-left-color: #999;
|
25
|
+
border-bottom-color: #BBB;
|
26
|
+
border-top: #B00100 solid 4px;
|
27
|
+
border-top-left-radius: 9px;
|
28
|
+
border-top-right-radius: 9px;
|
29
|
+
background-color: white;
|
30
|
+
padding: 7px 12% 0;
|
31
|
+
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
32
|
+
}
|
33
|
+
|
34
|
+
.rails-default-error-page h1 {
|
35
|
+
font-size: 100%;
|
36
|
+
color: #730E15;
|
37
|
+
line-height: 1.5em;
|
38
|
+
}
|
39
|
+
|
40
|
+
.rails-default-error-page div.dialog > p {
|
41
|
+
margin: 0 0 1em;
|
42
|
+
padding: 1em;
|
43
|
+
background-color: #F7F7F7;
|
44
|
+
border: 1px solid #CCC;
|
45
|
+
border-right-color: #999;
|
46
|
+
border-left-color: #999;
|
47
|
+
border-bottom-color: #999;
|
48
|
+
border-bottom-left-radius: 4px;
|
49
|
+
border-bottom-right-radius: 4px;
|
50
|
+
border-top-color: #DADADA;
|
51
|
+
color: #666;
|
52
|
+
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
53
|
+
}
|
54
|
+
</style>
|
55
|
+
</head>
|
56
|
+
|
57
|
+
<body class="rails-default-error-page">
|
58
|
+
<!-- This file lives in public/422.html -->
|
59
|
+
<div class="dialog">
|
60
|
+
<div>
|
61
|
+
<h1>The change you wanted was rejected.</h1>
|
62
|
+
<p>Maybe you tried to change something you didn't have access to.</p>
|
63
|
+
</div>
|
64
|
+
<p>If you are the application owner check the logs for more information.</p>
|
65
|
+
</div>
|
66
|
+
</body>
|
67
|
+
</html>
|
@@ -0,0 +1,66 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>We're sorry, but something went wrong (500)</title>
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
6
|
+
<style>
|
7
|
+
.rails-default-error-page {
|
8
|
+
background-color: #EFEFEF;
|
9
|
+
color: #2E2F30;
|
10
|
+
text-align: center;
|
11
|
+
font-family: arial, sans-serif;
|
12
|
+
margin: 0;
|
13
|
+
}
|
14
|
+
|
15
|
+
.rails-default-error-page div.dialog {
|
16
|
+
width: 95%;
|
17
|
+
max-width: 33em;
|
18
|
+
margin: 4em auto 0;
|
19
|
+
}
|
20
|
+
|
21
|
+
.rails-default-error-page div.dialog > div {
|
22
|
+
border: 1px solid #CCC;
|
23
|
+
border-right-color: #999;
|
24
|
+
border-left-color: #999;
|
25
|
+
border-bottom-color: #BBB;
|
26
|
+
border-top: #B00100 solid 4px;
|
27
|
+
border-top-left-radius: 9px;
|
28
|
+
border-top-right-radius: 9px;
|
29
|
+
background-color: white;
|
30
|
+
padding: 7px 12% 0;
|
31
|
+
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
32
|
+
}
|
33
|
+
|
34
|
+
.rails-default-error-page h1 {
|
35
|
+
font-size: 100%;
|
36
|
+
color: #730E15;
|
37
|
+
line-height: 1.5em;
|
38
|
+
}
|
39
|
+
|
40
|
+
.rails-default-error-page div.dialog > p {
|
41
|
+
margin: 0 0 1em;
|
42
|
+
padding: 1em;
|
43
|
+
background-color: #F7F7F7;
|
44
|
+
border: 1px solid #CCC;
|
45
|
+
border-right-color: #999;
|
46
|
+
border-left-color: #999;
|
47
|
+
border-bottom-color: #999;
|
48
|
+
border-bottom-left-radius: 4px;
|
49
|
+
border-bottom-right-radius: 4px;
|
50
|
+
border-top-color: #DADADA;
|
51
|
+
color: #666;
|
52
|
+
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
53
|
+
}
|
54
|
+
</style>
|
55
|
+
</head>
|
56
|
+
|
57
|
+
<body class="rails-default-error-page">
|
58
|
+
<!-- This file lives in public/500.html -->
|
59
|
+
<div class="dialog">
|
60
|
+
<div>
|
61
|
+
<h1>We're sorry, but something went wrong.</h1>
|
62
|
+
</div>
|
63
|
+
<p>If you are the application owner check the logs for more information.</p>
|
64
|
+
</div>
|
65
|
+
</body>
|
66
|
+
</html>
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
@@ -0,0 +1,400 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "test_helper"
|
4
|
+
|
5
|
+
class ActiveStorageEncryptionEncryptedBlobsControllerTest < ActionDispatch::IntegrationTest
|
6
|
+
setup do
|
7
|
+
@storage_dir = Dir.mktmpdir
|
8
|
+
@other_storage_dir = Dir.mktmpdir
|
9
|
+
@service = ActiveStorageEncryption::EncryptedDiskService.new(root: @storage_dir, private_url_policy: "stream")
|
10
|
+
@service.name = "amazing_encrypting_disk_service" # Needed for the controller and service lookup
|
11
|
+
|
12
|
+
# Hack: sneakily add our service to them configurations
|
13
|
+
# ActiveStorage::Blob.services.send(:services)["amazing_encrypting_disk_service"] = @service
|
14
|
+
|
15
|
+
# We need to set our service as the default, because the controller does lookup from the application config -
|
16
|
+
# which does not include the service we define here
|
17
|
+
@previous_default_service = ActiveStorage::Blob.service
|
18
|
+
@previous_services = ActiveStorage::Blob.services
|
19
|
+
|
20
|
+
# To catch potential issues where something goes to the default service by mistake, let's set a
|
21
|
+
# different Service as the default
|
22
|
+
@non_encrypted_default_service = ActiveStorage::Service::DiskService.new(root: @other_storage_dir)
|
23
|
+
ActiveStorage::Blob.service = @non_encrypted_default_service
|
24
|
+
ActiveStorage::Blob.services = {@service.name => @service} # That too
|
25
|
+
|
26
|
+
# This needs to be set
|
27
|
+
ActiveStorageEncryption::Engine.routes.default_url_options = {host: "www.example.com"}
|
28
|
+
|
29
|
+
# We need to use a hostname for ActiveStorage which is in the Rails authorized hosts.
|
30
|
+
# see https://stackoverflow.com/a/60573259/153886
|
31
|
+
ActiveStorage::Current.url_options = {
|
32
|
+
host: "www.example.com",
|
33
|
+
protocol: "https"
|
34
|
+
}
|
35
|
+
freeze_time # For testing expiring tokens
|
36
|
+
https! # So that all requests are simulated as SSL
|
37
|
+
end
|
38
|
+
|
39
|
+
def teardown
|
40
|
+
unfreeze_time
|
41
|
+
ActiveStorage::Blob.service = @previous_default_service
|
42
|
+
ActiveStorage::Blob.services = @previous_services
|
43
|
+
FileUtils.rm_rf(@storage_dir)
|
44
|
+
FileUtils.rm_rf(@other_storage_dir)
|
45
|
+
end
|
46
|
+
|
47
|
+
def engine_routes
|
48
|
+
ActiveStorageEncryption::Engine.routes.url_helpers
|
49
|
+
end
|
50
|
+
|
51
|
+
test "show() returns the decrypted blob body" do
|
52
|
+
rng = Random.new(Minitest.seed)
|
53
|
+
|
54
|
+
key = SecureRandom.base36(12)
|
55
|
+
encryption_key = rng.bytes(32)
|
56
|
+
plaintext = rng.bytes(512)
|
57
|
+
@service.upload(key, StringIO.new(plaintext).binmode, encryption_key: encryption_key)
|
58
|
+
|
59
|
+
streaming_url = @service.url(key, encryption_key: encryption_key, filename: ActiveStorage::Filename.new("private.doc"), expires_in: 30.seconds, disposition: "inline", content_type: "x-office/severance")
|
60
|
+
get streaming_url
|
61
|
+
|
62
|
+
assert_response :success
|
63
|
+
assert_equal "x-office/severance", response.headers["content-type"]
|
64
|
+
assert_equal plaintext, response.body
|
65
|
+
end
|
66
|
+
|
67
|
+
test "show() refuses a request which goes to a non-encrypted Service" do
|
68
|
+
rng = Random.new(Minitest.seed)
|
69
|
+
|
70
|
+
key = SecureRandom.base36(12)
|
71
|
+
encryption_key = rng.bytes(32)
|
72
|
+
plaintext = rng.bytes(512)
|
73
|
+
@service.upload(key, StringIO.new(plaintext).binmode, encryption_key: encryption_key)
|
74
|
+
|
75
|
+
streaming_url = @service.url(key, encryption_key: encryption_key, filename: ActiveStorage::Filename.new("private.doc"), expires_in: 30.seconds, disposition: "inline", content_type: "x-office/severance")
|
76
|
+
|
77
|
+
# Sneak in a non-encrypted service under the same key
|
78
|
+
ActiveStorage::Blob.services[@service.name] = @non_encrypted_default_service
|
79
|
+
|
80
|
+
get streaming_url
|
81
|
+
assert_response :forbidden
|
82
|
+
end
|
83
|
+
|
84
|
+
test "show() refuses a request which has an incorrect encryption key" do
|
85
|
+
rng = Random.new(Minitest.seed)
|
86
|
+
|
87
|
+
key = SecureRandom.base36(12)
|
88
|
+
encryption_key = rng.bytes(32)
|
89
|
+
plaintext = rng.bytes(512)
|
90
|
+
@service.upload(key, StringIO.new(plaintext).binmode, encryption_key: encryption_key)
|
91
|
+
|
92
|
+
another_encryption_key = rng.bytes(32)
|
93
|
+
refute_equal encryption_key, another_encryption_key
|
94
|
+
|
95
|
+
streaming_url = @service.url(key, encryption_key: another_encryption_key, filename: ActiveStorage::Filename.new("private.doc"), expires_in: 30.seconds, disposition: "inline", content_type: "x-office/severance")
|
96
|
+
get streaming_url
|
97
|
+
|
98
|
+
assert_response :forbidden
|
99
|
+
end
|
100
|
+
|
101
|
+
test "show() refuses a request with a garbage token" do
|
102
|
+
get engine_routes.encrypted_blob_streaming_get_path(token: "garbage", filename: "exfil.bin")
|
103
|
+
assert_response :forbidden
|
104
|
+
end
|
105
|
+
|
106
|
+
test "show() refuses a request with a token that has been encrypted using an incorrect encryption key" do
|
107
|
+
https!
|
108
|
+
rng = Random.new(Minitest.seed)
|
109
|
+
encryptor_key = rng.bytes(32)
|
110
|
+
other_encryptor = ActiveStorageEncryption::TokenEncryptor.new(encryptor_key, url_safe: encryptor_key)
|
111
|
+
|
112
|
+
key = SecureRandom.base36(12)
|
113
|
+
encryption_key = rng.bytes(32)
|
114
|
+
@service.upload(key, StringIO.new(rng.bytes(512)).binmode, encryption_key: encryption_key)
|
115
|
+
|
116
|
+
streaming_url = ActiveStorageEncryption.stub(:token_encryptor, -> { other_encryptor }) do
|
117
|
+
@service.url(key, encryption_key: encryption_key, filename: ActiveStorage::Filename.new("private.doc"), expires_in: 3.seconds, disposition: "inline", content_type: "binary/octet-stream")
|
118
|
+
end
|
119
|
+
|
120
|
+
get streaming_url
|
121
|
+
assert_response :forbidden
|
122
|
+
end
|
123
|
+
|
124
|
+
test "show() refuses a request with a token that has expired" do
|
125
|
+
rng = Random.new(Minitest.seed)
|
126
|
+
|
127
|
+
key = SecureRandom.base36(12)
|
128
|
+
encryption_key = rng.bytes(32)
|
129
|
+
@service.upload(key, StringIO.new(rng.bytes(512)).binmode, encryption_key: encryption_key)
|
130
|
+
|
131
|
+
streaming_url = @service.url(key, encryption_key: encryption_key, filename: ActiveStorage::Filename.new("private.doc"), expires_in: 3.seconds, disposition: "inline", content_type: "binary/octet-stream")
|
132
|
+
travel 5.seconds
|
133
|
+
|
134
|
+
get streaming_url
|
135
|
+
assert_response :forbidden
|
136
|
+
end
|
137
|
+
|
138
|
+
test "show() requires headers if the private_url_policy of the service is set to :require_headers" do
|
139
|
+
rng = Random.new(Minitest.seed)
|
140
|
+
|
141
|
+
key = SecureRandom.base36(12)
|
142
|
+
encryption_key = rng.bytes(32)
|
143
|
+
plaintext = rng.bytes(512)
|
144
|
+
@service.upload(key, StringIO.new(plaintext).binmode, encryption_key: encryption_key)
|
145
|
+
|
146
|
+
# The policy needs to be set before we generate the token (the token includes require_headers)
|
147
|
+
@service.private_url_policy = :require_headers
|
148
|
+
streaming_url = @service.url(key, encryption_key: encryption_key, filename: ActiveStorage::Filename.new("private.doc"), expires_in: 30.seconds, disposition: "inline", content_type: "x-office/severance")
|
149
|
+
|
150
|
+
get streaming_url
|
151
|
+
assert_response :forbidden # Without headers
|
152
|
+
|
153
|
+
get streaming_url, headers: {"HTTP_X_ACTIVE_STORAGE_ENCRYPTION_KEY" => Base64.strict_encode64(encryption_key)}
|
154
|
+
assert_response :success
|
155
|
+
assert_equal "x-office/severance", response.headers["content-type"]
|
156
|
+
assert_equal plaintext, response.body
|
157
|
+
end
|
158
|
+
|
159
|
+
test "show() refuses a request if the service no longer permits private URLs" do
|
160
|
+
rng = Random.new(Minitest.seed)
|
161
|
+
|
162
|
+
key = SecureRandom.base36(12)
|
163
|
+
encryption_key = rng.bytes(32)
|
164
|
+
plaintext = rng.bytes(512)
|
165
|
+
@service.upload(key, StringIO.new(plaintext).binmode, encryption_key: encryption_key)
|
166
|
+
|
167
|
+
streaming_url = @service.url(key, encryption_key: encryption_key, filename: ActiveStorage::Filename.new("private.doc"), expires_in: 30.seconds, disposition: "inline", content_type: "x-office/severance")
|
168
|
+
|
169
|
+
@service.private_url_policy = :disable
|
170
|
+
|
171
|
+
get streaming_url
|
172
|
+
assert_response :forbidden # Without headers
|
173
|
+
|
174
|
+
get streaming_url, headers: {"HTTP_X_ACTIVE_STORAGE_ENCRYPTION_KEY" => Base64.strict_encode64(encryption_key)}
|
175
|
+
assert_response :forbidden # Without headers
|
176
|
+
end
|
177
|
+
|
178
|
+
test "create_direct_upload creates a blob and returns the headers and the URL to start the upload, which are for the correct service name" do
|
179
|
+
rng = Random.new(Minitest.seed)
|
180
|
+
plaintext = rng.bytes(512)
|
181
|
+
|
182
|
+
params = {
|
183
|
+
service_name: @service.name,
|
184
|
+
blob: {
|
185
|
+
content_type: "x-binary/sensitive",
|
186
|
+
filename: "biometrics.sec",
|
187
|
+
checksum: Digest::MD5.base64digest(plaintext),
|
188
|
+
service_name: @service.name,
|
189
|
+
byte_size: plaintext.bytesize,
|
190
|
+
metadata: {womp: 1}
|
191
|
+
}
|
192
|
+
}
|
193
|
+
|
194
|
+
post engine_routes.create_encrypted_blob_direct_upload_url, params: params
|
195
|
+
|
196
|
+
assert_response :success
|
197
|
+
|
198
|
+
body_payload = JSON.parse(response.body, symbolize_names: true)
|
199
|
+
|
200
|
+
assert_equal "amazing_encrypting_disk_service", body_payload[:service_name]
|
201
|
+
assert_equal "biometrics.sec", body_payload[:filename]
|
202
|
+
assert_equal({womp: "1"}, body_payload[:metadata])
|
203
|
+
assert_equal Digest::MD5.base64digest(plaintext), body_payload[:checksum]
|
204
|
+
assert_kind_of String, body_payload[:direct_upload][:url]
|
205
|
+
assert_kind_of Hash, body_payload[:direct_upload][:headers]
|
206
|
+
assert_kind_of String, body_payload[:direct_upload][:headers][:"x-active-storage-encryption-key"]
|
207
|
+
|
208
|
+
blob = ActiveStorage::Blob.find_signed!(body_payload[:signed_id])
|
209
|
+
assert blob.encryption_key
|
210
|
+
assert_equal blob.service, @service
|
211
|
+
end
|
212
|
+
|
213
|
+
test "create_direct_upload creates a blob which can then be uploaded via PUT" do
|
214
|
+
rng = Random.new(Minitest.seed)
|
215
|
+
plaintext = rng.bytes(512)
|
216
|
+
|
217
|
+
params = {
|
218
|
+
service_name: @service.name,
|
219
|
+
blob: {
|
220
|
+
content_type: "x-binary/sensitive",
|
221
|
+
filename: "biometrics.sec",
|
222
|
+
checksum: Digest::MD5.base64digest(plaintext),
|
223
|
+
service_name: @service.name,
|
224
|
+
byte_size: plaintext.bytesize
|
225
|
+
}
|
226
|
+
}
|
227
|
+
|
228
|
+
post engine_routes.create_encrypted_blob_direct_upload_url, params: params
|
229
|
+
assert_response :success
|
230
|
+
|
231
|
+
body_payload = JSON.parse(response.body, symbolize_names: true)
|
232
|
+
url_to_put_to = body_payload[:direct_upload][:url]
|
233
|
+
headers = body_payload[:direct_upload][:headers]
|
234
|
+
put url_to_put_to, headers: headers, params: plaintext
|
235
|
+
assert_response :no_content
|
236
|
+
|
237
|
+
blob = ActiveStorage::Blob.find_signed!(body_payload[:signed_id])
|
238
|
+
readback = blob.download
|
239
|
+
assert_equal plaintext, readback
|
240
|
+
end
|
241
|
+
|
242
|
+
test "create_direct_upload refuses without being given an MD5 checksum" do
|
243
|
+
rng = Random.new(Minitest.seed)
|
244
|
+
plaintext = rng.bytes(512)
|
245
|
+
|
246
|
+
params = {
|
247
|
+
service_name: @service.name,
|
248
|
+
blob: {
|
249
|
+
content_type: "x-binary/sensitive",
|
250
|
+
filename: "biometrics.sec",
|
251
|
+
service_name: @service.name,
|
252
|
+
byte_size: plaintext.bytesize
|
253
|
+
}
|
254
|
+
}
|
255
|
+
|
256
|
+
post engine_routes.create_encrypted_blob_direct_upload_url, params: params
|
257
|
+
assert_response :unprocessable_entity
|
258
|
+
end
|
259
|
+
|
260
|
+
test "update() uploads the blob binary data to an encrypted Service using HTTP PUT" do
|
261
|
+
rng = Random.new(Minitest.seed)
|
262
|
+
plaintext = rng.bytes(512)
|
263
|
+
b64_md5 = Digest::MD5.base64digest(plaintext)
|
264
|
+
|
265
|
+
key = rng.hex(12)
|
266
|
+
encryption_key = rng.bytes(65)
|
267
|
+
|
268
|
+
headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5)
|
269
|
+
destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key)
|
270
|
+
|
271
|
+
put destination_url, headers: headers, params: plaintext
|
272
|
+
assert_response :no_content
|
273
|
+
|
274
|
+
assert @service.exist?(key)
|
275
|
+
readback = @service.download(key, encryption_key: encryption_key)
|
276
|
+
assert_equal plaintext, readback
|
277
|
+
end
|
278
|
+
|
279
|
+
test "update() refuses to upload if no Content-MD5 is sent with the request" do
|
280
|
+
rng = Random.new(Minitest.seed)
|
281
|
+
plaintext = rng.bytes(512)
|
282
|
+
b64_md5 = Digest::MD5.base64digest(plaintext)
|
283
|
+
|
284
|
+
key = rng.hex(12)
|
285
|
+
encryption_key = rng.bytes(65)
|
286
|
+
|
287
|
+
headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5)
|
288
|
+
destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key)
|
289
|
+
|
290
|
+
headers.delete_if { |k, _| k.downcase == "content-md5" }
|
291
|
+
put destination_url, headers: headers, params: plaintext
|
292
|
+
assert_response :unprocessable_entity
|
293
|
+
end
|
294
|
+
|
295
|
+
test "update() refuses to upload if Content-MD5 from headers differs from the one in the token" do
|
296
|
+
rng = Random.new(Minitest.seed)
|
297
|
+
plaintext = rng.bytes(512)
|
298
|
+
b64_md5 = Digest::MD5.base64digest(plaintext)
|
299
|
+
|
300
|
+
key = rng.hex(12)
|
301
|
+
encryption_key = rng.bytes(65)
|
302
|
+
|
303
|
+
headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5)
|
304
|
+
destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key)
|
305
|
+
|
306
|
+
wrong_md5 = Digest::MD5.base64digest(plaintext + "t")
|
307
|
+
headers["content-md5"] = wrong_md5
|
308
|
+
|
309
|
+
put destination_url, headers: headers, params: plaintext
|
310
|
+
assert_response :unprocessable_entity
|
311
|
+
end
|
312
|
+
|
313
|
+
test "update() refuses to upload if no encryption key is present in the header" do
|
314
|
+
rng = Random.new(Minitest.seed)
|
315
|
+
plaintext = rng.bytes(512)
|
316
|
+
b64_md5 = Digest::MD5.base64digest(plaintext)
|
317
|
+
|
318
|
+
key = rng.hex(12)
|
319
|
+
encryption_key = rng.bytes(65)
|
320
|
+
|
321
|
+
headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5)
|
322
|
+
destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key)
|
323
|
+
|
324
|
+
headers.delete_if { |k, _| k.downcase == "x-active-storage-encryption-key" }
|
325
|
+
put destination_url, headers: headers, params: plaintext
|
326
|
+
assert_response :unprocessable_entity
|
327
|
+
end
|
328
|
+
|
329
|
+
test "update() refuses to upload if plaintext is different to the one the checksum has been calculated for" do
|
330
|
+
rng = Random.new(Minitest.seed)
|
331
|
+
plaintext = rng.bytes(512)
|
332
|
+
b64_md5 = Digest::MD5.base64digest(plaintext)
|
333
|
+
|
334
|
+
key = rng.hex(12)
|
335
|
+
encryption_key = rng.bytes(65)
|
336
|
+
|
337
|
+
headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5)
|
338
|
+
destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key)
|
339
|
+
|
340
|
+
different_plaintext = rng.bytes(plaintext.bytesize)
|
341
|
+
refute_equal different_plaintext, plaintext
|
342
|
+
|
343
|
+
put destination_url, headers: headers, params: different_plaintext
|
344
|
+
assert_response :unprocessable_entity
|
345
|
+
|
346
|
+
refute @service.exist?(key)
|
347
|
+
end
|
348
|
+
|
349
|
+
test "update() refuses to upload if plaintext has a different length than stated during token generation" do
|
350
|
+
rng = Random.new(Minitest.seed)
|
351
|
+
plaintext = rng.bytes(512)
|
352
|
+
b64_md5 = Digest::MD5.base64digest(plaintext)
|
353
|
+
|
354
|
+
key = rng.hex(12)
|
355
|
+
encryption_key = rng.bytes(65)
|
356
|
+
|
357
|
+
headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5)
|
358
|
+
destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: (plaintext.bytesize - 44), checksum: b64_md5, encryption_key: encryption_key)
|
359
|
+
|
360
|
+
put destination_url, headers: headers, params: plaintext
|
361
|
+
assert_response :unprocessable_entity
|
362
|
+
end
|
363
|
+
|
364
|
+
test "update() refuses to upload if the encryption key given in the header is different than the one used to generate the URL" do
|
365
|
+
rng = Random.new(Minitest.seed)
|
366
|
+
plaintext = rng.bytes(512)
|
367
|
+
b64_md5 = Digest::MD5.base64digest(plaintext)
|
368
|
+
|
369
|
+
key = rng.hex(12)
|
370
|
+
encryption_key = rng.bytes(65)
|
371
|
+
|
372
|
+
headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5)
|
373
|
+
destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key)
|
374
|
+
|
375
|
+
other_encryption_key = rng.bytes(65)
|
376
|
+
refute_equal other_encryption_key, encryption_key
|
377
|
+
|
378
|
+
headers["x-active-storage-encryption-key"] = Base64.strict_encode64(other_encryption_key)
|
379
|
+
|
380
|
+
put destination_url, headers: headers, params: plaintext
|
381
|
+
assert_response :unprocessable_entity
|
382
|
+
end
|
383
|
+
|
384
|
+
test "update() refuses to upload if the token in the URL has expired" do
|
385
|
+
rng = Random.new(Minitest.seed)
|
386
|
+
plaintext = rng.bytes(512)
|
387
|
+
b64_md5 = Digest::MD5.base64digest(plaintext)
|
388
|
+
|
389
|
+
key = rng.hex(12)
|
390
|
+
encryption_key = rng.bytes(65)
|
391
|
+
|
392
|
+
headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5)
|
393
|
+
destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key)
|
394
|
+
|
395
|
+
travel 10.seconds
|
396
|
+
|
397
|
+
put destination_url, headers: headers, params: plaintext
|
398
|
+
assert_response :unprocessable_entity
|
399
|
+
end
|
400
|
+
end
|