Skip to content

Commit 70be18d

Browse files
Merge pull request #218 from scality/fix/gcs-presign-colon-encoding
Fix GCS presigned URL signature for multipart part uploads
2 parents 6805a8b + 08d997e commit 70be18d

2 files changed

Lines changed: 54 additions & 15 deletions

File tree

lua/compute_aws_s3_signature.lua

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,10 @@ elseif signature_mode == "PRESIGN_PUT" then
344344

345345
local expires = ngx.time() + 3600
346346
local aws_secret_key = os.getenv('AWS_SECRET_ACCESS_KEY')
347-
local canonicalized_resource = "/" .. ngx.var.aws_tgt_bucket .. "/" .. ngx.var.encoded_key
347+
-- GCS normalises ':' to '%3A' when computing StringToSign for presigned URLs;
348+
-- encode it here so the signature matches what GCS will verify.
349+
local url_safe_key = ngx.var.encoded_key:gsub(':', '%%3A')
350+
local canonicalized_resource = "/" .. ngx.var.aws_tgt_bucket .. "/" .. url_safe_key
348351
-- Sign as PUT with no Content-MD5 and no Content-Type (presigned, not proxied).
349352
local string_to_sign = "PUT\n\n\n" .. expires .. "\n" .. canonicalized_resource
350353
local aws_signature = ngx.encode_base64(ngx.hmac_sha1(aws_secret_key, string_to_sign))
@@ -373,17 +376,26 @@ elseif signature_mode == "PRESIGN_PART" then
373376
end
374377
local expires = ngx.time() + 3600
375378
local aws_secret_key = os.getenv('AWS_SECRET_ACCESS_KEY')
376-
-- GCS V2 requires URL-encoded values in the canonical resource (unlike AWS V2 which uses raw values).
377-
-- See: https://cloud.google.com/storage/docs/access-control/signed-urls-v2
378-
-- ngx.var.arg_* may return a raw (already percent-encoded) or decoded value depending on the nginx
379-
-- version. Normalize by unescaping first, then re-escaping to avoid double-encoding (%2B → %252B).
379+
-- GCS normalises ':' to '%3A' when computing StringToSign for presigned URLs;
380+
-- encode it here so the signature matches what GCS will verify.
381+
local url_safe_key = ngx.var.encoded_key:gsub(':', '%%3A')
382+
-- Normalise uploadId: ngx.var.arg_* may be pre-encoded or raw depending on the nginx
383+
-- version; unescape then re-escape to avoid double-encoding (%2B → %252B).
380384
local escaped_upload_id = ngx.escape_uri(ngx.unescape_uri(upload_id))
381-
-- subresources must appear in canonical resource (alphabetical: partNumber < uploadId)
382-
local canonicalized_resource = "/" .. ngx.var.aws_tgt_bucket .. "/" .. ngx.var.encoded_key ..
383-
"?partNumber=" .. part_number .. "&uploadId=" .. escaped_upload_id
385+
-- GCS does NOT include ?partNumber=N&uploadId=X in the canonical resource for presigned
386+
-- part PUTs. Standard AWS S3-compatible backends (cloudserver, Scaleway S3) do include
387+
-- them per the V2 spec. Detect GCS from ENDPOINT_URL to pick the right behaviour.
388+
local endpoint_url = os.getenv('ENDPOINT_URL') or ''
389+
local canonicalized_resource
390+
if endpoint_url:find('googleapis', 1, true) then
391+
canonicalized_resource = "/" .. ngx.var.aws_tgt_bucket .. "/" .. url_safe_key
392+
else
393+
canonicalized_resource = "/" .. ngx.var.aws_tgt_bucket .. "/" .. url_safe_key ..
394+
"?partNumber=" .. part_number .. "&uploadId=" .. escaped_upload_id
395+
end
384396
local string_to_sign = "PUT\n\n\n" .. expires .. "\n" .. canonicalized_resource
385397
local aws_signature = ngx.encode_base64(ngx.hmac_sha1(aws_secret_key, string_to_sign))
386-
local presigned_url = ngx.var.redirect_endpoint .. "/" .. ngx.var.aws_tgt_bucket .. "/" .. ngx.var.encoded_key ..
398+
local presigned_url = ngx.var.redirect_endpoint .. "/" .. ngx.var.aws_tgt_bucket .. "/" .. url_safe_key ..
387399
"?partNumber=" .. part_number ..
388400
"&uploadId=" .. escaped_upload_id ..
389401
"&AWSAccessKeyId=" .. ngx.escape_uri(ngx.var.aws_access_key) ..

tests/end2end/test_presign_upload.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"""
99

1010
import hashlib
11+
from urllib.parse import urlparse
1112

1213
import requests
1314

@@ -55,6 +56,17 @@ def test_presign_upload_file_reachable_after_direct_put(session, artifacts_url):
5556
assert dl.content == data
5657

5758

59+
def test_presign_upload_encodes_colons_in_url(session, artifacts_url):
60+
"""Presigned URL path must use %3A for ':' — GCS normalises ':' to '%3A'
61+
when computing StringToSign, so a literal ':' causes SignatureDoesNotMatch."""
62+
resp = session.get(f'{artifacts_url}/presign-upload/{STAGING_BUILD}/colon-check.txt')
63+
assert resp.status_code == 200
64+
url = resp.text.strip()
65+
path = urlparse(url).path
66+
assert '%3A' in path, f"Expected '%3A' in presigned URL path, got: {path!r}"
67+
assert ':' not in path, f"Raw ':' in presigned URL path causes SignatureDoesNotMatch on GCS"
68+
69+
5870
def test_presign_upload_rejects_non_get(session, artifacts_url):
5971
"""Only GET is allowed on /presign-upload/; other methods return 400."""
6072
url = f'{artifacts_url}/presign-upload/{STAGING_BUILD}/file.txt'
@@ -94,24 +106,39 @@ def test_presign_upload_part_returns_url(session, artifacts_url):
94106

95107
def test_presign_upload_part_encodes_special_chars_in_upload_id(session, artifacts_url):
96108
"""uploadId containing +, / and = (GCS-style base64) is percent-encoded in the presigned URL."""
97-
# Use a synthetic uploadId with base64 special characters. The PRESIGN_PART
98-
# endpoint does not validate that the uploadId corresponds to a real upload,
99-
# so we can inject one directly to verify the encoding behaviour without
100-
# needing a backend that generates such IDs.
101109
special_upload_id = 'abc+def/ghi=jkl'
102110
resp = session.get(
103111
f'{artifacts_url}/presign-upload-part/{STAGING_BUILD}/presign/encoding-test.bin',
104112
params={'partNumber': 1, 'uploadId': special_upload_id},
105113
)
106114
assert resp.status_code == 200, f'{resp.status_code} {resp.text}'
107115
url = resp.text.strip()
108-
# The uploadId must appear percent-encoded in the presigned URL so that GCS
109-
# can reconstruct the canonical resource from the URL literally (GCS V2 spec).
110116
assert 'uploadId=abc%2Bdef%2Fghi%3Djkl' in url, (
111117
f'Expected uploadId to be percent-encoded in presigned URL, got: {url!r}'
112118
)
113119

114120

121+
def test_presign_upload_part_encodes_colons_in_url(session, artifacts_url):
122+
"""Presigned part URL path must use %3A for ':' — GCS normalises ':' to '%3A'
123+
when computing StringToSign, so a literal ':' causes SignatureDoesNotMatch."""
124+
upload_id = multipart_initiate(session, artifacts_url, STAGING_BUILD, 'presign/colon-part.bin')
125+
try:
126+
resp = session.get(
127+
f'{artifacts_url}/presign-upload-part/{STAGING_BUILD}/presign/colon-part.bin',
128+
params={'partNumber': 1, 'uploadId': upload_id},
129+
)
130+
assert resp.status_code == 200, f'{resp.status_code} {resp.text}'
131+
url = resp.text.strip()
132+
path = urlparse(url).path
133+
assert '%3A' in path, f"Expected '%3A' in presigned part URL path, got: {path!r}"
134+
assert ':' not in path, f"Raw ':' in presigned part URL path causes SignatureDoesNotMatch on GCS"
135+
finally:
136+
session.delete(
137+
f'{artifacts_url}/upload-multipart/abort/{STAGING_BUILD}/presign/colon-part.bin',
138+
params={'uploadId': upload_id},
139+
)
140+
141+
115142
def test_presign_multipart_full_round_trip(session, artifacts_url):
116143
"""Initiate via nginx, upload parts directly to S3, complete via nginx."""
117144
build = STAGING_BUILD

0 commit comments

Comments
 (0)