From 76d4781fb3b10f1e34470d34c81acf8d7f8ea5cb Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:38:55 -0500 Subject: [PATCH] Sign an encoded slash in an S3 path as a separator S3 and MinIO decode the path they receive, then sign it with each segment URI-encoded once, slashes kept. HttpAwsSigV4Helper split the path before decoding each segment, so an encoded slash stayed inside its segment: `/bucket/a%2Fb` was signed as `/bucket/a%2Fb` while S3 signs `/bucket/a/b`, and the signature didn't match. For S3 the whole path is now decoded before it is split. The other services still sign the path normalized and encoded once more, as sent. Found while reviewing the copy of this helper in Ikanos (naftiko/ikanos#957). Co-Authored-By: Claude Opus 5.5 --- .../crypto/internal/HttpAwsSigV4Helper.java | 19 +++++++++++-------- .../internal/HttpAwsSigV4HelperTestCase.java | 11 +++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/org.restlet.ext.crypto/src/main/java/org/restlet/ext/crypto/internal/HttpAwsSigV4Helper.java b/org.restlet.ext.crypto/src/main/java/org/restlet/ext/crypto/internal/HttpAwsSigV4Helper.java index 52c028e928..209452ad96 100644 --- a/org.restlet.ext.crypto/src/main/java/org/restlet/ext/crypto/internal/HttpAwsSigV4Helper.java +++ b/org.restlet.ext.crypto/src/main/java/org/restlet/ext/crypto/internal/HttpAwsSigV4Helper.java @@ -70,10 +70,11 @@ * HeaderConstants#ATTRIBUTE_RAW_REQUEST_HEADERS} attribute, invalidates the signature. * *

The signed path and query are those the client connector sends: a relative reference is - * resolved against its base. S3 signs the path as is, each segment decoded then URI-encoded once. - * The other services sign it normalized (empty and dot segments removed) and URI-encoded once more - * as sent, so an escape in the path is encoded again. Query parameters are signed sorted and - * URI-encoded: a {@code +} in the query is a literal plus sign, so encode spaces as {@code %20}. + * resolved against its base. S3 signs the path as is, decoded (an encoded slash included) then each + * segment URI-encoded once. The other services sign it normalized (empty and dot segments removed) + * and URI-encoded once more as sent, so an escape in the path is encoded again. Query parameters + * are signed sorted and URI-encoded: a {@code +} in the query is a literal plus sign, so encode + * spaces as {@code %20}. * * @see Create @@ -357,8 +358,10 @@ static String getCanonicalRequest( /** * Returns the canonical URI. * - *

S3 decodes the path it receives and encodes it again: each segment is decoded, then - * URI-encoded once, and the path is otherwise signed as sent. + *

S3 decodes the path it receives and encodes it again: the whole path is decoded, then + * split into segments, each URI-encoded once, and the path is otherwise signed as sent. An + * encoded slash ({@code %2F}) is decoded before the split, so it is signed as a separator, as + * S3 and MinIO do ({@code /bucket/a%2Fb} is signed as {@code /bucket/a/b}). * *

The other services encode the path they receive once more ("double encoding"): the path is * normalized (see {@link #normalizePath(String)}) and each raw segment is URI-encoded as is, so @@ -373,14 +376,14 @@ static String getCanonicalUri(String rawPath, boolean doubleEncode) { if (rawPath == null || rawPath.isEmpty()) { return "/"; } - String path = doubleEncode ? normalizePath(rawPath) : rawPath; + String path = doubleEncode ? normalizePath(rawPath) : percentDecode(rawPath); String[] segments = path.split("/", -1); StringBuilder sb = new StringBuilder(); for (int i = 0; i < segments.length; i++) { if (i > 0) { sb.append('/'); } - sb.append(uriEncode(doubleEncode ? segments[i] : percentDecode(segments[i]))); + sb.append(uriEncode(segments[i])); } return sb.charAt(0) == '/' ? sb.toString() : "/" + sb; } diff --git a/org.restlet.ext.crypto/src/test/java/org/restlet/ext/crypto/internal/HttpAwsSigV4HelperTestCase.java b/org.restlet.ext.crypto/src/test/java/org/restlet/ext/crypto/internal/HttpAwsSigV4HelperTestCase.java index 773abc8859..6c8a529b21 100644 --- a/org.restlet.ext.crypto/src/test/java/org/restlet/ext/crypto/internal/HttpAwsSigV4HelperTestCase.java +++ b/org.restlet.ext.crypto/src/test/java/org/restlet/ext/crypto/internal/HttpAwsSigV4HelperTestCase.java @@ -390,6 +390,17 @@ void canonicalUri() { assertEquals("/a%2Bb%25zz", HttpAwsSigV4Helper.getCanonicalUri("/a+b%zz", false)); } + /** + * S3 and MinIO decode the whole path before splitting it, so an encoded slash is signed as a + * separator. The other services sign it as sent, encoded once more. + */ + @Test + void canonicalUriDecodesAnEncodedSlashBeforeSplittingForS3Only() { + assertEquals("/bucket/a/b", HttpAwsSigV4Helper.getCanonicalUri("/bucket/a%2Fb", false)); + assertEquals("/bucket/a/b", HttpAwsSigV4Helper.getCanonicalUri("/bucket/a%2fb", false)); + assertEquals("/bucket/a%252Fb", HttpAwsSigV4Helper.getCanonicalUri("/bucket/a%2Fb", true)); + } + /** * The services other than S3 sign the path normalized, as in the "normalize-path" cases of the * AWS SigV4 test suite. S3 signs it as sent.