Azure-stored object storage files ending with dot are inaccessible
Summary
Uploading a file ending with .
(no extension) returns an invalid URL w/ signature when trying to open it afterwards.
Azure explicitly mentions to avoid this in their docs:
Avoid blob names that end with a dot (.), a forward slash (/), or a sequence or combination of the two. No path segments should end with a dot (.).
Example debugging with a file called test.
:
irb(main):001:0> file = Upload.last
=> #<Upload id: 4, size: 2239, path: "@hashed/d4/73/d4735e3a265e16eee03f59718b9b5d03019c...", checksu...
irb(main):002:0> upload = file.retrieve_uploader
=> #<FileUploader:0x00007f843e40f078 @model=#<Project id:2 root/p1>>, @mounted_as=nil, @file=#<Carrie...
irb(main):003:0>
irb(main):004:0> credentials = upload.fog_credentials
=> {:provider=>"AzureRM", :azure_storage_account_name=>"catalinrepro", :azure_storage_access_key=>"X+...
irb(main):005:0> provider = credentials[:provider]
=> "AzureRM"
irb(main):006:0> upload.path
=> "@hashed/d4/73/d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35/4842ef328558dd3e3b430308259541b1/test."
irb(main):007:0> Gitlab::HTTP.get(upload.url).body
=> "\xEF\xBB\xBF<?xml version=\"1.0\" encoding=\"utf-8\"?><Error><Code>AuthenticationFailed</Code><Message>Server failed to authenticate the request. Make sure the value of Authorization header is formed correctly including the signature.\nRequestId:bf10e155-101e-005a-7ce5-524482000000\nTime:2021-05-27T10:45:28.7077931Z</Message><AuthenticationErrorDetail>Signature did not match. String to sign used was r\n\n2021-05-27T10:55:28Z\n/blob/catalinrepro/uploads/@hashed/d4/73/d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35/4842ef328558dd3e3b430308259541b1/test\n\n\nhttps\n2018-11-09\nb\n\n\n\n\n\n</AuthenticationErrorDetail></Error>"
Based on our carrierwave handling - we end up in gitlab-fog-azure-rm, where:
connection = upload.file.send(:connection)
local_directory = connection.directories.new(key: upload.fog_directory)
local_file = local_directory.files.new(key: file.path)
expire_at = ::Fog::Time.now + 10.minutes
irb(main):023:0> Gitlab::HTTP.get(local_file.url(expire_at)).body
=> "\xEF\xBB\xBF<?xml version=\"1.0\" encoding=\"utf-8\"?><Error><Code>AuthenticationFailed</Code><Message>Server failed to authenticate the request. Make sure the value of Authorization header is formed correctly including the signature.\nRequestId:899e4c97-201e-0041-02e5-527a81000000\nTime:2021-05-27T10:48:20.7765734Z</Message><AuthenticationErrorDetail>Signature did not match. String to sign used was r\n\n2021-05-27T10:58:10Z\n/blob/catalinrepro/uploads/@hashed/d4/73/d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35/4842ef328558dd3e3b430308259541b1/test\n\n\nhttps\n2018-11-09\nb\n\n\n\n\n\n</AuthenticationErrorDetail></Error>"
Still the same, going down the call stack, we generate the SAS token using Azure::Storage::Common::Core::Auth::SharedAccessSignature
, so let's see how this works:
Azure::Storage::Common::Core::Auth::SharedAccessSignature.alias_method(:old_method, :generate_service_sas_token)
Azure::Storage::Common::Core::Auth::SharedAccessSignature.define_method(:generate_service_sas_token) do |relative_path, params|
path2 = relative_path
pp [path2, params]
ret = self.old_method(path2, params)
pp ret
ret
end
irb(main):033:0> Gitlab::HTTP.get(local_file.url(expire_at)).ok?
["uploads/@hashed/d4/73/d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35/4842ef328558dd3e3b430308259541b1/test.",
{:service=>"b",
:resource=>"b",
:permissions=>"r",
:expiry=>"2021-05-27T11:04:28Z",
:protocol=>"https"}]
"sp=r&sv=2018-11-09&sr=b&se=2021-05-27T11%3A04%3A28Z&spr=https&sig=..."
=> false
# fun enough, simply stripping the ending dot works:
Azure::Storage::Common::Core::Auth::SharedAccessSignature.define_method(:generate_service_sas_token) do |relative_path, params|
path2 = relative_path.slice(..-2)
pp [path2, params]
ret = self.old_method(path2, params)
pp ret
ret
end
irb(main):028:0> Gitlab::HTTP.get(local_file.url(expire_at)).ok?
["uploads/@hashed/d4/73/d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35/4842ef328558dd3e3b430308259541b1/test",
{:service=>"b",
:resource=>"b",
:permissions=>"r",
:expiry=>"2021-05-27T11:29:28Z",
:protocol=>"https"}]
"sp=r&sv=2018-11-09&sr=b&se=2021-05-27T11%3A29%3A28Z&spr=https&sig=..."
=> true
Interestingly - this goes back to the azure-storage gem then.
Poking around further:
Azure::Storage::Common::Core::Auth::SharedAccessSignature.define_method(:generate_service_sas_token) do |*args|
binding.irb
self.old_method(*args)
end
irb(main):042:0> Gitlab::HTTP.get(local_file.url(expire_at)).ok?
irb(#<Azure::Storage::Common::Core::Auth::SharedAccessSignature:0x00007f1013e39890>):001:0> signable_string_for_service('blob', args[0], args[1])
=> "r\n\n2021-05-27T11:29:28Z\n/blob/catalinrepro/uploads/@hashed/d4/73/d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35/4842ef328558dd3e3b430308259541b1/test.\n\n\nhttps\n2018-11-09\nb\n\n\n\n\n\n"
We do actually see the string signed contains the final .
, as expected, however Azure says the string used (on their end, to validate the signature) was, from the above:
r\n\n2021-05-27T10:58:10Z\n/blob/catalinrepro/uploads/@hashed/d4/73/d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35/4842ef328558dd3e3b430308259541b1/test\n\n\nhttps\n2018-11-09\nb\n\n\n\n\n\n
The diff is exactly the .
, which means Azure doesn't really consider the last .
when sent to the filename in the URL - which is kind of outside of our control, not sure if there is something we can do about this.
Steps to reproduce
- Configure object storage using Azure
- Upload a file ending with
.
to an issue (for example) - Attempt to view the file, get a signature mismatch error