This is an automated email from the ASF dual-hosted git repository.
pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-connectors.git
The following commit(s) were added to refs/heads/main by this push:
new fe51527f2 S3: Allow custom non-standard reserved characters (#1971)
fe51527f2 is described below
commit fe51527f2b8b5e4558e5d0d21b2c6e2652a5e0fe
Author: liloew <[email protected]>
AuthorDate: Tue Oct 20 03:19:07 2026 +0800
S3: Allow custom non-standard reserved characters (#1971)
* S3: Allow custom non-standard reserved characters
* Fix `Status code: 403 Forbidden` error when using pekko-connectors-s3
with Huawei OBS and Tencent COS.
* refactor: adopt idiomatic Scala syntax and unify imports
* refactor: optimize and unify imports
* S3: add configurable path encoding for compatible services
Add `signing.path-encoded-characters` to support S3-compatible
services whose signature validation differs from AWS S3.
Document the Huawei OBS and Tencent COS compatibility setting, validate
that `/` is not included, and add tests covering configuration parsing,
custom character sets, empty values, and canonical path encoding.
* S3: address review comments on path-encoded-characters
Motivation:
Remaining review comments on the configurable path encoding: the key in
reference.conf made the setting always present, the docs example value
did the opposite of what was described, and the Signer import was out
of order.
Modification:
Comment out `signing.path-encoded-characters` in reference.conf so an
unset value really means "use the RFC 3986 default". Correct the docs
and reference.conf to recommend ":?#[]$'()*+=" for Huawei OBS and
Tencent COS based on the reported test matrix. Fix the Signer import
grouping and minor typos.
Result:
The default-settings spec passes and the documented value matches the
observed vendor behaviour.
Tests:
sbt "s3/testOnly *CanonicalRequestSpec *SignerSpec *S3SettingsSpec"
sbt "s3/mimaReportBinaryIssues"
---------
Co-authored-by: PJ Fanning <[email protected]>
---
docs/src/main/paradox/s3.md | 43 +++++++++++++++
s3/src/main/resources/reference.conf | 19 +++++++
.../connectors/s3/impl/auth/CanonicalRequest.scala | 12 +++--
.../stream/connectors/s3/impl/auth/Signer.scala | 3 +-
.../pekko/stream/connectors/s3/settings.scala | 55 ++++++++++++++-----
.../stream/connectors/s3/S3SettingsSpec.scala | 31 +++++++++++
.../s3/impl/auth/CanonicalRequestSpec.scala | 63 ++++++++++++++++++++++
.../connectors/s3/impl/auth/SignerSpec.scala | 3 ++
8 files changed, 212 insertions(+), 17 deletions(-)
diff --git a/docs/src/main/paradox/s3.md b/docs/src/main/paradox/s3.md
index ef02678f0..74fe0e245 100644
--- a/docs/src/main/paradox/s3.md
+++ b/docs/src/main/paradox/s3.md
@@ -196,6 +196,49 @@ Java
: @@snip [snip](/s3/src/test/java/docs/javadsl/S3Test.java) {
#check-if-bucket-exists }
+## Compatibility
+
+The connector is mainly written for [Amazon S3](https://aws.amazon.com/s3/),
but many other object stores expose an S3-compatible API and can be used with
it.
+However, these compatible services do not always behave exactly like AWS S3,
and a few differences affect how requests have to be signed or sent.
+This section describes the settings that exist for such services and the
behaviour that is known so far.
+It is based on testing against specific services and not on a guarantee from
the connector or the vendors,
+so if you hit a difference that is not covered here, please report it.
+
+### Services that sign the path differently (Huawei OBS, Tencent COS and
others)
+
+AWS S3 re-encodes the request path before checking the signature. Some
S3-compatible services do not,
+and reject requests with `403 Forbidden` (`SignatureDoesNotMatch`) when an
object key contains certain
+characters, for example `@`, `;`, `,`, `&` or `!`. Plain keys are not affected.
+
+By default the [RFC 3986 reserved
characters](https://tools.ietf.org/html/rfc3986#section-2.2) are
+percent-encoded in the path when the signature is calculated:
+
+```
+:?#[]@!$&'()*+,;=
+```
+
+`signing.path-encoded-characters` replaces that set. `/` is deliberately not
in it: it must stay literal
+in the signed path, so a value containing `/` is rejected.
+
+In testing against Huawei OBS and Tencent COS, keys containing `@`, `!`, `&`,
`,` or `;` only worked when
+those characters were removed from the set, while keys containing `=` (Tencent
COS) and `+` (both services)
+only worked when they stayed in it. Based on those results, the following
value (the default without `@!&,;`)
+is the one to try for both services:
+
+```hocon
+pekko.connectors.s3 {
+ signing.path-encoded-characters = ":?#[]$'()*+="
+}
+```
+
+Notes:
+
+* The value must be a quoted string. Unquoted, HOCON treats `#` as the start
of a comment and rejects `[`, `]` and `$`.
+* This is based on observed behaviour, not on vendor documentation. Characters
that were not tested
+ (`$`, `'`, `(`, `)`, `*` and `:`) are left as in the default.
+* Programmatic equivalent:
`S3Settings(...).withReservedCharacters(":?#[]$'()*+=")`.
+
+
## Running the example code
The code in this guide is part of runnable tests of this project. You are
welcome to edit the code and run it in sbt.
diff --git a/s3/src/main/resources/reference.conf
b/s3/src/main/resources/reference.conf
index 7045c80ed..c95cbe910 100644
--- a/s3/src/main/resources/reference.conf
+++ b/s3/src/main/resources/reference.conf
@@ -342,4 +342,23 @@ pekko.connectors.s3 {
PutBucketVersioning = []
GetBucketVersioning = []
}
+
+ # Characters that are percent-encoded in the request path when the AWS
+ # Signature V4 canonical request is calculated. When this setting is not set,
+ # the RFC 3986 reserved characters (excluding `/`) are encoded, as AWS S3
expects:
+ # https://tools.ietf.org/html/rfc3986#section-2.2
+ #
+ # Only change this for S3-compatible services (e.g. Huawei OBS, Tencent COS)
whose
+ # signature validation encodes the path differently from AWS S3. The symptom
is
+ # `403 Forbidden` / `SignatureDoesNotMatch` for object keys containing some
of
+ # these characters, while plain keys work. For Huawei OBS and Tencent COS,
try
+ # ":?#[]$'()*+=" (the default without `@`, `!`, `&`, `,` and `;`); see the S3
+ # documentation for the observed behaviour.
+ #
+ # `/` must stay literal in the signed path, so a value containing `/` is
rejected.
+ #
+ # The value MUST be a quoted string. Unquoted, HOCON treats `#` as the start
of
+ # a comment and rejects `[`, `]` and `$`, so the override either fails to
parse
+ # or is silently cut short.
+ # signing.path-encoded-characters = ":?#[]@!$&'()*+,;="
}
diff --git
a/s3/src/main/scala/org/apache/pekko/stream/connectors/s3/impl/auth/CanonicalRequest.scala
b/s3/src/main/scala/org/apache/pekko/stream/connectors/s3/impl/auth/CanonicalRequest.scala
index ad7212e78..5bf196a2d 100644
---
a/s3/src/main/scala/org/apache/pekko/stream/connectors/s3/impl/auth/CanonicalRequest.scala
+++
b/s3/src/main/scala/org/apache/pekko/stream/connectors/s3/impl/auth/CanonicalRequest.scala
@@ -18,6 +18,7 @@ import pekko.annotation.InternalApi
import pekko.http.scaladsl.model.Uri.{ Path, Query }
import pekko.http.scaladsl.model.headers.{ `Raw-Request-URI`,
`Timeout-Access`, `Tls-Session-Info`, `X-Forwarded-For` }
import pekko.http.scaladsl.model.{ HttpHeader, HttpRequest }
+import pekko.stream.connectors.s3.S3Settings
// Documentation:
http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
@InternalApi private[impl] final case class CanonicalRequest(
@@ -38,7 +39,7 @@ import pekko.http.scaladsl.model.{ HttpHeader, HttpRequest }
`Timeout-Access`.lowercaseName,
`Tls-Session-Info`.lowercaseName)
- def from(request: HttpRequest): CanonicalRequest = {
+ def from(request: HttpRequest)(implicit conf: S3Settings): CanonicalRequest
= {
val hashedBody =
request.headers
.collectFirst { case header if header.is("x-amz-content-sha256") =>
header.value }
@@ -63,8 +64,11 @@ import pekko.http.scaladsl.model.{ HttpHeader, HttpRequest }
// Excludes "/" as it is an exception according to spec.
val reservedCharacters: String = ":?#[]@!$&'()*+,;="
- def isReservedCharacter(c: Char): Boolean =
- reservedCharacters.contains(c)
+ def isReservedCharacter(c: Char)(implicit conf: S3Settings): Boolean =
+ conf.reservedCharacters match {
+ case Some(chars) => chars.contains(c)
+ case None => reservedCharacters.contains(c)
+ }
def canonicalQueryString(query: Query): String = {
def uriEncode(s: String): String = s.flatMap {
@@ -96,7 +100,7 @@ import pekko.http.scaladsl.model.{ HttpHeader, HttpRequest }
def signedHeadersString(headers: Seq[HttpHeader]): String =
headers.map(_.lowercaseName).distinct.sorted.mkString(";")
- def pathEncode(path: Path): String =
+ def pathEncode(path: Path)(implicit conf: S3Settings): String =
if (path.isEmpty) "/"
else {
path.toString.flatMap {
diff --git
a/s3/src/main/scala/org/apache/pekko/stream/connectors/s3/impl/auth/Signer.scala
b/s3/src/main/scala/org/apache/pekko/stream/connectors/s3/impl/auth/Signer.scala
index ac26cb15a..0eac2d276 100644
---
a/s3/src/main/scala/org/apache/pekko/stream/connectors/s3/impl/auth/Signer.scala
+++
b/s3/src/main/scala/org/apache/pekko/stream/connectors/s3/impl/auth/Signer.scala
@@ -22,6 +22,7 @@ import pekko.NotUsed
import pekko.annotation.InternalApi
import pekko.http.scaladsl.model.headers.RawHeader
import pekko.http.scaladsl.model.{ HttpHeader, HttpRequest }
+import pekko.stream.connectors.s3.S3Settings
import pekko.stream.scaladsl.Source
@InternalApi private[impl] object Signer {
@@ -29,7 +30,7 @@ import pekko.stream.scaladsl.Source
def signedRequest(request: HttpRequest,
key: SigningKey,
- signAnonymousRequests: Boolean): Source[HttpRequest, NotUsed] =
+ signAnonymousRequests: Boolean)(implicit conf: S3Settings):
Source[HttpRequest, NotUsed] =
if (!signAnonymousRequests && key.anonymous) Source.single(request)
else {
val hashedBody = request.entity.dataBytes.via(digest()).map(hash =>
encodeHex(hash))
diff --git
a/s3/src/main/scala/org/apache/pekko/stream/connectors/s3/settings.scala
b/s3/src/main/scala/org/apache/pekko/stream/connectors/s3/settings.scala
index 691d69183..1b4b9c44f 100644
--- a/s3/src/main/scala/org/apache/pekko/stream/connectors/s3/settings.scala
+++ b/s3/src/main/scala/org/apache/pekko/stream/connectors/s3/settings.scala
@@ -365,9 +365,13 @@ final class S3Settings private (
val retrySettings: RetrySettings,
val multipartUploadSettings: MultipartUploadSettings,
val signAnonymousRequests: Boolean,
- val allowedHeaders: Map[String, Set[String]]
+ val allowedHeaders: Map[String, Set[String]],
+ val reservedCharacters: Option[String]
) {
+ /** Java API */
+ def getReservedCharacters: java.util.Optional[String] =
reservedCharacters.toJava
+
/** Java API */
def getBufferType: BufferType = bufferType
@@ -427,6 +431,12 @@ final class S3Settings private (
def withSignAnonymousRequests(value: Boolean): S3Settings =
if (signAnonymousRequests == value) this else copy(signAnonymousRequests =
value)
+ def withReservedCharacters(value: String): S3Settings =
+ reservedCharacters match {
+ case Some(`value`) => this
+ case _ => copy(reservedCharacters =
Option(S3Settings.validateReservedCharacters(value)))
+ }
+
private[s3] val concreteAllowedHeaders: Map[S3Request, Set[String]] = {
allowedHeaders.foldLeft(Map.empty[S3Request, Set[String]]) {
case (acc, (header, value)) =>
@@ -450,7 +460,8 @@ final class S3Settings private (
retrySettings: RetrySettings = retrySettings,
multipartUploadSettings: MultipartUploadSettings =
multipartUploadSettings,
signAnonymousRequests: Boolean = signAnonymousRequests,
- allowedHeaders: Map[String, Set[String]] = allowedHeaders
+ allowedHeaders: Map[String, Set[String]] = allowedHeaders,
+ reservedCharacters: Option[String] = reservedCharacters
): S3Settings = new S3Settings(
bufferType,
credentialsProvider,
@@ -463,7 +474,8 @@ final class S3Settings private (
retrySettings,
multipartUploadSettings,
signAnonymousRequests,
- allowedHeaders
+ allowedHeaders,
+ reservedCharacters
)
override def toString: String =
@@ -475,16 +487,17 @@ final class S3Settings private (
s"endpointUrl=$endpointUrl," +
s"listBucketApiVersion=$listBucketApiVersion," +
s"forwardProxy=$forwardProxy," +
- s"validateObjectKey=$validateObjectKey" +
- s"retrySettings=$retrySettings" +
- s"multipartUploadSettings=$multipartUploadSettings" +
- s"signAnonymousRequests=$signAnonymousRequests" +
+ s"validateObjectKey=$validateObjectKey," +
+ s"retrySettings=$retrySettings," +
+ s"multipartUploadSettings=$multipartUploadSettings," +
+ s"signAnonymousRequests=$signAnonymousRequests," +
s"allowedHeaders=${
val entries = allowedHeaders.toSeq.sortBy(_._1).map { case (key,
values) =>
s"$key -> Set(${values.mkString(", ")})"
}.mkString(", ")
s"Map($entries)"
- }" +
+ }," +
+ s"reservedCharacters=$reservedCharacters" +
")"
override def equals(other: Any): Boolean = other match {
@@ -500,7 +513,8 @@ final class S3Settings private (
Objects.equals(this.retrySettings, that.retrySettings) &&
Objects.equals(this.multipartUploadSettings, multipartUploadSettings) &&
this.signAnonymousRequests == that.signAnonymousRequests &&
- this.allowedHeaders == that.allowedHeaders
+ this.allowedHeaders == that.allowedHeaders &&
+ Objects.equals(this.reservedCharacters, that.reservedCharacters)
case _ => false
}
@@ -516,13 +530,23 @@ final class S3Settings private (
Boolean.box(validateObjectKey),
retrySettings,
multipartUploadSettings,
- Boolean.box(signAnonymousRequests))
+ Boolean.box(signAnonymousRequests),
+ allowedHeaders,
+ reservedCharacters)
}
object S3Settings {
private final val log = LoggerFactory.getLogger(getClass)
val ConfigPath = "pekko.connectors.s3"
+ private val PathEncodedCharacterPath = "signing.path-encoded-characters"
+
+ private def validateReservedCharacters(value: String): String = {
+ require(!value.contains("/"),
+ s"'$PathEncodedCharacterPath' must not contain '/': it has to stay
literal in the signed path. Got: [$value]")
+ value
+ }
+
/**
* Reads from the given config.
*/
@@ -689,6 +713,11 @@ object S3Settings {
k -> (v ++ allowedHeadersBase.getOrElse(k, Set.empty[String]))
}
+ val reservedCharacters =
+ Option.when(c.hasPath(PathEncodedCharacterPath)) {
+ validateReservedCharacters(c.getString(PathEncodedCharacterPath))
+ }
+
new S3Settings(
bufferType,
credentialsProvider,
@@ -701,7 +730,8 @@ object S3Settings {
retrySettings,
multipartUploadSettings,
signAnonymousRequests,
- finalAllowedHeaders
+ finalAllowedHeaders,
+ reservedCharacters
)
}
@@ -743,7 +773,8 @@ object S3Settings {
RetrySettings.default,
MultipartUploadSettings(RetrySettings.default),
signAnonymousRequests = true,
- allowedHeaders = allowedHeaders
+ allowedHeaders = allowedHeaders,
+ reservedCharacters = None
)
/** Java API */
diff --git
a/s3/src/test/scala/org/apache/pekko/stream/connectors/s3/S3SettingsSpec.scala
b/s3/src/test/scala/org/apache/pekko/stream/connectors/s3/S3SettingsSpec.scala
index 935fc0cbe..075bfb7f0 100644
---
a/s3/src/test/scala/org/apache/pekko/stream/connectors/s3/S3SettingsSpec.scala
+++
b/s3/src/test/scala/org/apache/pekko/stream/connectors/s3/S3SettingsSpec.scala
@@ -335,4 +335,35 @@ class S3SettingsSpec extends S3WireMockBase with
S3ClientIntegrationSpec with Op
}
}
+ it should "have no signing.path-encoded-characters configured by default" in
{
+ val settings = mkSettings("")
+ settings.reservedCharacters shouldBe empty
+ settings.getReservedCharacters.isPresent shouldBe false
+ }
+
+ it should "parse signing.path-encoded-characters" in {
+ val settings = mkSettings(
+ """
+ |signing.path-encoded-characters = "!()"
+ |""".stripMargin)
+ settings.reservedCharacters shouldBe Some("!()")
+ settings.getReservedCharacters.get shouldBe "!()"
+ }
+
+ it should "parse an empty signing.path-encoded-characters value" in {
+ val settings = mkSettings(
+ """
+ |signing.path-encoded-characters = ""
+ |""".stripMargin)
+ settings.reservedCharacters shouldBe Some("")
+ }
+
+ it should "reject signing.path-encoded-characters containing '/'" in {
+ an[IllegalArgumentException] should be thrownBy
+ mkSettings(
+ """
+ |signing.path-encoded-characters = "/!"
+ |""".stripMargin)
+ }
+
}
diff --git
a/s3/src/test/scala/org/apache/pekko/stream/connectors/s3/impl/auth/CanonicalRequestSpec.scala
b/s3/src/test/scala/org/apache/pekko/stream/connectors/s3/impl/auth/CanonicalRequestSpec.scala
index f17877cd0..e29e06a24 100644
---
a/s3/src/test/scala/org/apache/pekko/stream/connectors/s3/impl/auth/CanonicalRequestSpec.scala
+++
b/s3/src/test/scala/org/apache/pekko/stream/connectors/s3/impl/auth/CanonicalRequestSpec.scala
@@ -17,11 +17,31 @@ import org.apache.pekko
import pekko.http.scaladsl.model.Uri.Query
import pekko.http.scaladsl.model._
import pekko.http.scaladsl.model.headers._
+import pekko.stream.connectors.s3.{ ApiVersion, BufferType, MemoryBufferType,
S3Settings }
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
+import software.amazon.awssdk.auth.credentials.{ AnonymousCredentialsProvider,
AwsCredentialsProvider }
+import software.amazon.awssdk.regions.Region
+import software.amazon.awssdk.regions.providers.AwsRegionProvider
class CanonicalRequestSpec extends AnyFlatSpec with Matchers {
+ def getSettings(
+ bufferType: BufferType = MemoryBufferType,
+ awsCredentials: AwsCredentialsProvider =
AnonymousCredentialsProvider.create(),
+ s3Region: Region = Region.US_EAST_1,
+ listBucketApiVersion: ApiVersion = ApiVersion.ListBucketVersion2,
+ reservedCharacters: Option[String] = None) = {
+ val regionProvider = new AwsRegionProvider {
+ def getRegion = s3Region
+ }
+
+ val base = S3Settings(bufferType, awsCredentials, regionProvider,
listBucketApiVersion, Map.empty)
+ reservedCharacters.fold(base)(base.withReservedCharacters)
+ }
+
+ implicit val settings: S3Settings = getSettings()
+
it should "correctly build a canonicalString for eu-central-1" in {
val req = HttpRequest(
HttpMethods.GET,
@@ -153,6 +173,49 @@ class CanonicalRequestSpec extends AnyFlatSpec with
Matchers {
}
}
+ private def pathRequest(path: Uri.Path): HttpRequest =
+ HttpRequest(
+ HttpMethods.GET,
+ Uri(s"https://mytestbucket.s3.us-east-1.amazonaws.com")
+ .withPath(path)).withHeaders(
+ RawHeader("x-amz-content-sha256", "testhash"),
+ `Content-Type`(ContentTypes.`application/json`))
+
+ private def expectedCanonical(encodedPath: String): String =
+ s"""GET
+ |$encodedPath
+ |
+ |content-type:application/json
+ |x-amz-content-sha256:testhash
+ |
+ |content-type;x-amz-content-sha256
+ |testhash""".stripMargin
+
+ it should "encode the default reserved characters when none are configured"
in {
+ val req = pathRequest(Uri.Path.Empty / "file-(1)!.txt")
+ CanonicalRequest.from(req).canonicalString should equal(
+ expectedCanonical("/file-%281%29%21.txt")
+ )
+ }
+
+ it should "only encode the configured reserved characters, leaving other
default ones unencoded" in {
+ // '(' and ')' are in the default set, but not in the custom one
+ val customSettings = getSettings(reservedCharacters = Some("!"))
+ val req = pathRequest(Uri.Path.Empty / "file-(1)!.txt")
+ val canonicalRequest = CanonicalRequest.from(req)(customSettings)
+ canonicalRequest.canonicalString should
equal(expectedCanonical("/file-(1)%21.txt"))
+
+ // sanity check: the same request is encoded differently with the default
settings
+ (CanonicalRequest.from(req).canonicalString should
not).equal(canonicalRequest.canonicalString)
+ }
+
+ it should "not encode any path characters when reserved character is empty"
in {
+ val customSettings = getSettings(reservedCharacters = Some(""))
+ val req = pathRequest(Uri.Path.Empty / "file-(1)!.txt")
+
+ CanonicalRequest.from(req)(customSettings).canonicalString should
equal(expectedCanonical("/file-(1)!.txt"))
+ }
+
it should "correctly build a canonicalString when synthetic headers are
present" in {
val req = HttpRequest(
HttpMethods.GET,
diff --git
a/s3/src/test/scala/org/apache/pekko/stream/connectors/s3/impl/auth/SignerSpec.scala
b/s3/src/test/scala/org/apache/pekko/stream/connectors/s3/impl/auth/SignerSpec.scala
index 8463aa227..94133a4e9 100644
---
a/s3/src/test/scala/org/apache/pekko/stream/connectors/s3/impl/auth/SignerSpec.scala
+++
b/s3/src/test/scala/org/apache/pekko/stream/connectors/s3/impl/auth/SignerSpec.scala
@@ -19,6 +19,7 @@ import org.apache.pekko
import pekko.actor.ActorSystem
import pekko.http.scaladsl.model.headers.{ `Raw-Request-URI`, Host, RawHeader }
import pekko.http.scaladsl.model.{ HttpMethods, HttpRequest }
+import pekko.stream.connectors.s3.S3Settings
import pekko.stream.connectors.testkit.scaladsl.LogCapturing
import pekko.stream.scaladsl.Sink
import pekko.testkit.TestKit
@@ -45,6 +46,8 @@ class SignerSpec(_system: ActorSystem)
implicit val defaultPatience: PatienceConfig =
PatienceConfig(timeout = Span(2, Seconds), interval = Span(5, Millis))
+ implicit val settings: S3Settings = S3Settings()
+
val credentials = StaticCredentialsProvider.create(
AwsBasicCredentials.create("AKIDEXAMPLE",
"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"))
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]