👌

Amazon S3 のカスタムドメイン で 署名付きURLを発行してファイルをアップロードする方法

に公開

先日とある質問がありました。S3の署名付きURLでカスタムドメインをベースとしたURLは発行できるか?

ChatGPTなどで調べると、できませんと明言されます。

んな、あほなと。

正確にはその場合、CloudFrontを使ってください、が公式の回答のようです。ただCloudFrontを使いたくても使えないケースはいっぱいあります。特に代表的なのはDirectConnectとPrivateVIF接続をされていたりして、通信がパブリックに出せないケースです。

経験上、多くの人が困りそうなことは大体AWSの機能を使って頑張ればできることが多いので、今回もできるんじゃないかな?と思って調べてみました。

re:Postの回答

https://repost.aws/questions/QUP7a9RiOQRXiTbPsdOQnZ8Q/s3-presigned-url-custom-domain-name
こういう記事を見つけました。その後この記事の実装用terraformが提供されていることがわかりました。
https://docs.aws.amazon.com/ja_jp/prescriptive-guidance/latest/patterns/set-up-private-access-to-an-amazon-s3-bucket-through-a-vpc-endpoint.html

この記事からわかったことは3つです。
1.なんかできそう
2.署名付きURLは原則HTTPSを前提とする(HTTPでも動作するらしいが試していない)ので、カスタムドメインのTLS/SNI処理を誰かが代わりにおこなってあげれば行けそう
3.S3 Outpostsは環境ごとにバケットのドメインが異なるため、異なるドメインへの署名付きURLは機能としては存在していそう

ということで検証を開始しましたが、壁に当たりました。まずRoute53で管理可能なドメインを持っていませんでした。ドメインを購入しなくて済むように、適当なPrivateHostedゾーンでドメインを作ったのですが、その次に必要となるのはAmazon Certificate Managerによる証明書発行です。証明書発行のためには、Amazon Certificate AuthorityでCAを建てる必要があるのですが、月最低400ドルはかかるようです。AWS使い放題の昔はよかったなぁと思いつつこの案はあきらめました。

構成図を見るとその他ALBやAPI Gateway、Lambda等てんこ盛りでしたのでこの方面での実装は厳しいなというのもありました。

Cloudflare でCNAME

S3に張れるドメインで最も簡単にTLS用電子証明書を発行してくれるのは、やっぱりCloudflareです。もともと私がCloudflareで検証用ドメインを持っていた、というのはありますが、それを除いてもTLS/SNI証明書をほぼ無設定で発行してくれるのはすごいです。(レイオフされた恨みはありますが、やっぱりCloudflareは大好き笑)

で、結論から言うと、CloudflareでCNAME設定を行うことでカスタムドメインの署名付きURLは動作しました

さっそくやってみる

では検証を行います。まずはS3バケットを作るのですが、この際必ずCNAMEとしてカスタムドメインを張るFQDN名と同じバケットを作成してください。 なぜかといえばそうしないと署名付きURLのS3側の検証が失敗します。例外的にS3はバケット名と同じドメイン名を用いた署名付きURLは受け付けるようになっている、までしか現時点ではわかっていないのですがもう少し詳細理解したら追記します。

通常の署名付きURL

まずは以下を試します。

test.js
const fs = require('fs');
const axios = require('axios');
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const { NodeHttpHandler } = require("@smithy/node-http-handler");
const { resolveEndpointConfig } = require("@aws-sdk/middleware-endpoint");
const { normalizeProvider } = require("@smithy/util-middleware");

const REGION = 'ap-northeast-1';
const BUCKET_NAME = 's3.harunobukameda.com';
const ACCESS_KEY_ID = 'xxxxx';
const SECRET_ACCESS_KEY = 'yyyyy';
const FILE_PATH = 'index.html';
const S3_KEY = 'index.html';

const s3Client = new S3Client({
  region: REGION,
  credentials: {
    accessKeyId: ACCESS_KEY_ID,
    secretAccessKey: SECRET_ACCESS_KEY,
  },
});

async function uploadFile() {
  const fileData = fs.readFileSync(FILE_PATH);
  const contentType = 'text/html';

  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: S3_KEY,
    ContentType: contentType,
  });

  const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 600 });
  console.log('Presigned URL:', presignedUrl);

  const response = await axios.put(presignedUrl, fileData, {
    headers: {
      'Content-Type': contentType,
    },
  });

  if (response.status === 200) {
    console.log('✅ アップロード成功:', S3_KEY);
  } else {
    console.error('❌ アップロード失敗', response.statusText);
  }
}

uploadFile().catch(console.error);

node test.jsを実行すればいかがレスポンスとして戻ります。

Presigned URL: https://s3.ap-northeast-1.amazonaws.com/s3.harunobukameda.com/index.html?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=AKIA5LIXG4WVD2GK3EHN%2F20250415%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20250415T121506Z&X-Amz-Expires=600&X-Amz-Signature=e2db875b2018bcb2008e1e1d2c4d71fa7eb6e815156ac2e6236a78b6e37246d6&X-Amz-SignedHeaders=host&x-amz-checksum-crc32=AAAAAA%3D%3D&x-amz-sdk-checksum-algorithm=CRC32&x-id=PutObject
✅ アップロード成功: index.html

Cloudflare でCNAME設定

次にCloudflareでCNAMEを設定します。

s3.harunobukameda.com(バケット名と同じ)→s3.ap-northeast-1.amazonaws.com

この際オレンジモードにします。オレンジモードとはAliasレコードと似ているCloudflareの機能で、CNAMEにもかかわらずAレコードの様に名前を直接解決します。つまりIPアドレスで直接S3にアクセスを行います。

オレンジモードを使わない場合、s3.harunobukameda.comで生成された署名付きURLをs3.ap-northeast-1.amazonaws.comへ投げ込むのでエラーとなります。

❌ アップロード失敗: Hostname/IP does not match certificate's altnames: Host: s3.harunobukameda.com. is not in the cert's altnames: DNS:s3-ap-northeast-1.amazonaws.com, DNS:*.s3-ap-northeast-1.amazonaws.com, DNS:s3.ap-northeast-1.amazonaws.com, DNS:*.s3.ap-northeast-1.amazonaws.com, DNS:s3.dualstack.ap-northeast-1.amazonaws.com, DNS:*.s3.dualstack.ap-northeast-1.amazonaws.com, DNS:*.s3.amazonaws.com, DNS:*.s3-control.ap-northeast-1.amazonaws.com, DNS:s3-control.ap-northeast-1.amazonaws.com, DNS:*.s3-control.dualstack.ap-northeast-1.amazonaws.com, DNS:s3-control.dualstack.ap-northeast-1.amazonaws.com, DNS:*.s3-accesspoint.ap-northeast-1.amazonaws.com, DNS:*.s3-accesspoint.dualstack.ap-northeast-1.amazonaws.com, DNS:*.s3-deprecated.ap-northeast-1.amazonaws.com, DNS:s3-deprecated.ap-northeast-1.amazonaws.com

次にスクリプトを以下に修正します。

customdomain.js
const fs = require('fs');
const axios = require('axios');
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');

const REGION = 'ap-northeast-1';
const BUCKET_NAME = 'https://s3.harunobukameda.com';
const ACCESS_KEY_ID = 'xxxxxxx';
const SECRET_ACCESS_KEY = 'yyyyyyyy';

const FILE_PATH = 'final.js';
const S3_KEY = 'final.js';

const s3Client = new S3Client({
  region: REGION,
  credentials: {
    accessKeyId: ACCESS_KEY_ID,
    secretAccessKey: SECRET_ACCESS_KEY,
  },
  endpoint: 'https://s3.harunobukameda.com',
  forcePathStyle: false,
  bucketEndpoint: true, // ← これが重要!
});

async function uploadFile() {
  const fileData = fs.readFileSync(FILE_PATH);
  const contentType = 'text/javascript';

  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: S3_KEY,
    ContentType: contentType,
  });

  try {
    const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 600 });
    console.log('🟢 Presigned URL:', presignedUrl);

    const response = await axios.put(presignedUrl, fileData, {
      headers: {
        'Content-Type': contentType,
      }
    });

    console.log('✅ アップロード成功:', S3_KEY);
    console.log('📁 ファイルURL:', `https://s3.harunobukameda.com/${S3_KEY}`);
    console.log('📊 レスポンスステータス:', response.status);
  } catch (error) {
    console.error('❌ アップロード失敗:', error.message);
    if (error.response) {
      console.error('レスポンスステータス:', error.response.status);
      console.error('レスポンスデータ:', error.response.data);
    }
  }
}

uploadFile().catch(console.error);

重要なのは以下の部分です。

  endpoint: 'https://s3.harunobukameda.com',
  forcePathStyle: false,
  bucketEndpoint: true, // ← これが重要!

これにより、以下のようなPresignedURLが生成されます。

🟢 Presigned URL: https://s3.harunobukameda.com/final.js?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=AKIA5LIXG4WVD2GK3EHN%2F20250415%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20250415T122408Z&X-Amz-Expires=600&X-Amz-Signature=24582686ff4bf41eb26ae75d24e882123cc83adb7915c5171907641b02700290&X-Amz-SignedHeaders=host&x-amz-checksum-crc32=AAAAAA%3D%3D&x-amz-sdk-checksum-algorithm=CRC32&x-id=PutObject

追加検証が必要なこと

今回は通常のCNAMEではなくCloudflareのCNAME(Aレコードの様にS3バケットのIPアドレスが戻ってくるモード)で動作が成功しています。
普通のCNAMEの様に

s3.harunobukameda.com(バケット名と同じ)→s3.ap-northeast-1.amazonaws.com

であれば証明書の検証、要はホストヘッダー検証が失敗します。ここは注意点ですが、Route53のAliasレコードでも同じ動作で成功するはずですので、別途検証したいと思います。(検証に400ドル以上かかるんすわ)

2025/04/18

NLBでもできた!
・IPアドレスでクライアントが最終的にS3へアクセスすること(クライアントがカスタムドメインの名前解決をしないこと)→間にNLBを入れることでNLBが直接IPアドレスでS3へアクセスを行うことでクリア
・ホストヘッダにカスタムドメインが設定されていること(ここがS3標準URLだと署名検証が失敗する)
・カスタムドメインがバケット名と同じであること
が条件のようです。

Discussion