Amazon S3の特定のキーのオブジェクトはマネジメントコンソール上からダウンロードできない

2023.03.09

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

意外とあり得そうな事象にかかわらず、つい最近まで出会ったことがなかったのでご紹介します。

経緯

フォルダ階層をそのまま保持してアップロードする処理を書いていたのですが、
パスの指定や取得の関係で./から始まる相対パスを指定しアップロードしてしまいました。

この時点では特に問題なくアップロードでき
一般的なOS側で特殊パス的に指定される値もいけるんだなと驚いていたのですが.
確認の際にマネジメントコンソールからダウンロードする際にエラーとなってしまいました。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/object-keys.html#object-key-guidelines
また、以下のプレフィックスの制約についても注意してください。
プレフィックスが「./」のオブジェクトは、AWS Command Line Interface (AWS CLI)、AWS SDK、または REST API を使用してアップロードまたはダウンロードします。Amazon S3 コンソールを使用することはできません。
プレフィックスが「../」のオブジェクトは、AWS Command Line Interface (AWS CLI) または Amazon S3 コンソールを使用してアップロードすることはできません。

ドキュメントを確認したところ./から始まるパスについては
マネジメントコンソールから取り扱えないがその他の方法であれば完全に取り扱えないというものではなさそうです。

SDKからのアップロードを確認する

少なくとも自分はWindowsやMacで./から始まるファイル名の作成方法を知らず、
改めて調べても見当たらないため今回SDK(for Rust)経由のみの確認となります。

実際のコードとは少し置き換えていますがシンプルにPutObjectの実行です。

//binary: アップロードするファイルデータ
async fn upload(binary: Cursor<Vec<u8>>) {
    let config = aws_config::load_from_env().await;
    let s3 = aws_sdk_s3::Client::new(&config);
    let result = s3.put_object().bucket("hoge-bucket")
                    .key("./xxxx.zip")
                    .body(ByteStream::from(binary.into_inner()))
                    .send()
                    .await;
}

実行しアップロードするとバケット直下に./から始まるパスが確認できます。

マネジメントコンソールから一切何もできないというわけではなく各種情報の参照等は正常にできます。
削除可能なことも確認しました。

キーの先頭が../のオブジェクトのアップロードについてはドキュメント記載の通り利用できずプログラム側でエラーが確認できました。

PutObjectError { kind: Unhandled(Unhandled { source: Error { code: None, message: None, request_id: None, extras: {} } }), meta: Error { code: None, message: None, request_id: None, extras: {} } }

マネジメントコンソールからダウンロードする

アップロードしたファイルのを一覧画面の「ダウンロード」より実際にダウンロードしてみます。

エラーとなりました。

今回の検証で初めて知りましたがマネジメントコンソールからのダウンロードはどうやら署名付きURLで提供されていたようです。

AWS側の仕様として拒否しているのかなと思いましたが
CanonicalRequestの部分をよくみるとGET /./target/.DS_StoreではなくGET /target/.DS_Storeになっていました。

/./がなくなるのはブラウザやcurlで別のサイトにアクセスした際にも発生したためS3の仕様ではなさそうです。

% curl --verbose https://dev.classmethod.jp/author/./suzuki-junya/
...
> GET /author/suzuki-junya/ HTTP/2
> Host: dev.classmethod.jp

ここまでの動作を気にしたことはなかったのですが調べてみると
URIの構文定義であるRFC3986のセクション5.2.4で.または..のセグメントについては整形をし削除する仕様がありました。

https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4

こちらの仕様にのっとりクライアント側で手を入れていそうです。

SDK経由でアップロードできる謎について

SDKでのアップロードも最終的にはREST APIでの操作となります。

アップロードできる理由についてオブジェクトのキーがパス以外の何らかの指定のためかと思っていましたが、 PutObjectのAPIを確認するとオブジェクトのキーはパスに含まれるようです。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/API/API_PutObject.html#API_PutObject_RequestSyntax
PUT /Key+ HTTP/1.1
Host: Bucket.s3.amazonaws.com
...

PutObjectの証跡をCloudTrailを利用して追跡したところ、
特に/./セグメントが除去されないままPutObjectが実行されていることが確認されているので、
何か特別な処理を施しているわけではなさそうです。

{
      ...
      "eventTime": "2023-03-08T13:34:28Z",
      "eventSource": "s3.amazonaws.com",
      "eventName": "PutObject",
      "awsRegion": "ap-northeast-1",
      "sourceIPAddress": "xxx",
      "userAgent": "[aws-sdk-rust/0.54.1 os/macos lang/rust/1.67.1]",
      "requestParameters": {
        "bucketName": "xxxxxx",
        "Host": "xxxx.s3.ap-northeast-1.amazonaws.com",
        "key": "./target/CACHEDIR.TAG.zip",
        "x-id": "PutObject"
      },
      ...
}

nginxのコンテナを実行環境上に立ち上げエンドポイントをそちらに向けてみたところ
特に整形されずにリクエストされてることがアクセスログよりわかりました。

172.17.0.1 - - [08/Mar/2023:15:38:48 +0000] "PUT /xxxxx/./target/rls.zip?x-id=PutObject HTTP/1.1" 405 157 "-" "aws-sdk-rust/0.54.1 os/macos lang/rust/1.67.1" "-"
172.17.0.1 - - [08/Mar/2023:15:38:48 +0000] "PUT /xxxxx/./target/.DS_Store.zip?x-id=PutObject HTTP/1.1" 405 157 "-" "aws-sdk-rust/0.54.1 os/macos lang/rust/1.67.1" "-"
172.17.0.1 - - [08/Mar/2023:15:38:48 +0000] "PUT /xxxxx/./target/.rustc_info.json.zip?x-id=PutObject HTTP/1.1" 405 157 "-" "aws-sdk-rust/0.54.1 os/macos lang/rust/1.67.1" "-"

全てのSDKが該当するかは不明ですが、
少なくともfor RustのSDKではこの部分の成形を行わないことで./から始まるキーのオブジェクトの操作を実現しているようです

署名付きURLも同じ方法でアクセスすると

SDK経由のPutObjectがこの形であれば署名付きURLも/./を取り除かなければ取得できそうな予感がします。

試してみます。

注意点としてリクエスト利用するライブラリは非常に重要でブラウザやcurlのように/./のセグメントが除去されるように整形される可能性があります。

少なくともFireFoxのブラウザの検証ツールの再送信の加工、pythonのrequests、JavaScriptのfetch、Rustのreqwestについては整形されました。

いくつか試したところRustのhyperではこの成形が発生しなかった為こちらで実行してみます。

urlはマネジメントコンソールのダウンロード実行を行い表示された画面からコピーをし、
有効期限が切れないうちにホスト名の直後に./のパスを挟み実行しました。

use hyper_tls::HttpsConnector;
use hyper::{Client, Uri};

#[tokio::main]
async fn main() {
    let url = "https://xxxxxx.s3.ap-northeast-1.amazonaws.com/./target/.DS_Store.zip?xxxxxx";
    let https = HttpsConnector::new();
    let client = Client::builder().build::<_, hyper::Body>(https);

    let res = client.get(Uri::from_static(url));
    println!("{:?}", res.await.unwrap());
}

取得失敗であればstatusは403になるのですが、
上記のコードを実行したところstatusは200となり取得に成功していることがわかります。

Response { status: 200, version: HTTP/1.1, headers: {"x-amz-id-2": "xxxxx", "x-amz-request-id": "xxxx", "date": "Thu, 09 Mar 2023 02:10:59 GMT", "last-modified": "Wed, 08 Mar 2023 13:34:29 GMT", "etag": "\"xxxxx\"", "x-amz-server-side-encryption": "AES256", "content-disposition": "attachment", "accept-ranges": "bytes", "content-type": "application/octet-stream", "server": "AmazonS3", "content-length": "421"}, body: Body(Streaming) }

つまりマネジメントコンソールでキーの先頭が./で始まるオブジェクトのダウンロードが利用できないのは
AWS側の仕組みとして対応していないというわけではなく、
おそらくは受け取る側の機能の問題によって実行できないものということがわかります。

終わりに

さて今回はキーが./から始まるオブジェクトについて幾つかの挙動を試してみました。

ここまで詰める理由があったかは不明ですが、
なぜそれがダメかという部分まで理解を得られ収穫はあったかと思います。

さて当初のアップロードできない特定のパスという話に戻りますが、
大元を辿るとAPIはHTTPでの実行となりURIの仕様の影響を受けていることがわかりました。

今回の件に関わらずAPIがHTTP経由で実行される以上その中で特殊な扱いがされる可能性のある文字列をもし利用される場合は
AWS側の仕様だけはなく受け取る側の仕様の影響を受ける部分があることを考慮に入れて検討していただければと思います。