👋 はじめに
こんにちは。今回の記事ですが、Windowsアプリのセルフアップデート機能を作るにあたって、S3に置いた更新パッケージをセキュアに取得する仕組みが必要になりました。
ポイントは次の3つです。
- S3バケットは非公開にしたい(URLを知っていれば誰でもダウンロードできる状態は避けたい)
- クライアントアプリにAWS SDKは入れたくない(バイナリサイズ増加 + プロキシ対応が面倒)
- 企業ネットワーク(プロキシ環境)でも確実に動く必要がある
そこで、API Gateway + Lambda で署名付きURLを発行し、クライアントはWinHTTPでGETするだけという構成を作りました。
実際のプロダクトはもう少し複雑ですが、この記事では本質だけに絞って最小限の構成をご説明します。
🤔 なぜこの構成にしたか
❌ S3を公開にする案
最初は「S3をPublicにしてHTTPS GETするだけ」で考えていました。シンプルで良いのですが、バケットのURLを知っていれば誰でもダウンロードできてしまいます。配布物自体は電子署名で改ざん防止しているので実害は少ないものの、セキュリティレビューで「非公開にできないか」という話になりました。
❌ AWS SDKを使う案
S3を非公開にしてAWS SDKで取得する方法もあります。ただし、AWS SDK(.NET or C++)を使うと:
- NuGetパッケージが数MB増える
- プロキシ解決が独自実装になる(WinHTTPのOS標準プロキシ探索が使えない)
- IAMアクセスキーをクライアントに持たせる必要がある
特にプロキシの件が痛いです。PAC経由で複数プロキシを探索する企業ネットワークでは、WinHTTPのネイティブなプロキシ解決が圧倒的に安定します。
✅ 署名付きURL方式(採用)
結局、こうなりました:
- クライアントはAPI Gatewayに「このファイルの署名付きURLください」とリクエスト
- Lambdaが有効期限5分の署名付きURL(GET専用)を生成して返却
- クライアントはその署名付きURLでS3から直接ダウンロード
API GatewayへのアクセスはAPIキーで制御します。APIキーはC++バイナリにハードコードする方針としました。詳細はセキュリティの節で後述します。
🏗️ 構成図
┌─────────────────┐ ① GET /presigned-url ┌──────────────────┐
│ C++ アプリ │ ─────(x-api-key ヘッダ)────▶ │ API Gateway │
│ (WinHTTP) │ │ (APIキー認証) │
└─────────────────┘ └────────┬─────────┘
│ │ ② Invoke
│ ▼
│ ┌──────────────────┐
│ │ Lambda │
│ │ (URL生成) │
│ └────────┬─────────┘
│ │ ③ 署名付きURL生成
│ ④ 署名付きURLをレスポンス │ (s3:GetObject権限)
│◀────────────────────────────────────────────────────┘
│
│ ⑤ GET 署名付きURL
└──────────────────────────────────────────▶┌──────────────────┐
│ S3 (非公開) │
└──────────────────┘
☁️ AWS側のインフラ(CDK)
TypeScriptでCDKスタックを書きます。ポイントは次の通りです:
- S3バケット:
BlockPublicAccess.BLOCK_ALLで完全非公開 - Lambda実行ロール:
s3:GetObjectのみ(Write権限なし) - API Gateway: APIキー必須 + スロットリング(10 req/s, 月間10,000リクエスト)
- 署名付きURL: 有効期限300秒(5分)、GETメソッド専用
📦 CDKスタック(抜粋)
import * as path from "path";
import * as cdk from "aws-cdk-lib";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as iam from "aws-cdk-lib/aws-iam";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
// S3 バケット(非公開)
const updateBucket = new s3.Bucket(this, "UpdateBucket", {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
});
// Lambda(署名付きURL生成)
const presignedUrlFunction = new lambda.Function(this, "PresignedUrlGenerator", {
runtime: lambda.Runtime.NODEJS_20_X,
handler: "index.handler",
code: lambda.Code.fromAsset(path.join(__dirname, "..", "lambda")),
environment: {
BUCKET_NAME: updateBucket.bucketName,
URL_EXPIRATION_SECONDS: "300",
ALLOWED_PREFIX: "myapp/",
},
});
// Lambda に GetObject のみ許可(Write は絶対に付与しない)
presignedUrlFunction.addToRolePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ["s3:GetObject"],
resources: [`${updateBucket.bucketArn}/myapp/*`],
})
);
// API Gateway + APIキー + スロットリング
const api = new apigateway.RestApi(this, "UpdateApi", {
deployOptions: {
throttlingRateLimit: 10,
throttlingBurstLimit: 20,
},
});
const usagePlan = api.addUsagePlan("UsagePlan", {
throttle: { rateLimit: 10, burstLimit: 20 },
quota: { limit: 10000, period: apigateway.Period.MONTH },
});
⚡ Lambda関数
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3Client = new S3Client({});
export const handler = async (event) => {
try {
const key = event.queryStringParameters?.key;
// プレフィックス制限(バケット内の他ファイルへのアクセス防止)
if (!key || !key.startsWith(process.env.ALLOWED_PREFIX)) {
return { statusCode: 403, body: JSON.stringify({ error: "Access denied" }) };
}
const command = new GetObjectCommand({
Bucket: process.env.BUCKET_NAME,
Key: key,
});
const presignedUrl = await getSignedUrl(s3Client, command, {
expiresIn: parseInt(process.env.URL_EXPIRATION_SECONDS),
});
return {
statusCode: 200,
body: JSON.stringify({ url: presignedUrl, expiresIn: 300 }),
};
} catch (error) {
console.error("Failed to generate pre-signed URL:", error);
return {
statusCode: 500,
body: JSON.stringify({ error: "Internal server error" }),
};
}
};
💻 C++ クライアント側の実装
🔄 全体の流れ
int wmain() {
WinHttpClient client;
client.Initialize();
// ① API Gateway から署名付きURL取得
auto presignedUrl = client.GetPresignedUrl(API_HOST, apiPath, 443, API_KEY);
// ② 署名付きURLでS3からファイルダウンロード
client.DownloadFile(*presignedUrl, DOWNLOAD_PATH);
}
🌐 WinHTTP初期化
bool WinHttpClient::Initialize() {
// WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY で OS のプロキシ設定を自動使用
m_hSession = WinHttpOpen(
L"MyApp-SelfUpdate/1.0",
WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
WINHTTP_NO_PROXY_NAME,
WINHTTP_NO_PROXY_BYPASS,
0
);
return m_hSession != nullptr;
}
WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY を指定すると、WinHTTPが自動検出、PAC、固定プロキシの順でプロキシを解決してくれます。企業ネットワークでPACファイルに複数プロキシが定義されている環境でも、これだけで動きます。
🔑 API Gateway への署名付きURL取得リクエスト
std::optional<std::string> WinHttpClient::GetPresignedUrl(
const std::wstring& host, const std::wstring& path,
int port, const std::wstring& apiKey)
{
std::wstring header = L"x-api-key: " + apiKey;
auto response = HttpGet(host, path, port, true, header);
// JSON レスポンスから "url" フィールドを抽出
std::string body(response->begin(), response->end());
std::string presignedUrl = ExtractJsonValue(body, "url");
return presignedUrl;
}
ヘッダに x-api-key を付与してGETするだけです。レスポンスのJSONから署名付きURLを取り出して返します。
なお、ExtractJsonValue はJSON文字列から指定キーの値を抽出するユーティリティ関数です。本サンプルでは nlohmann/json を使用して実装しています。レスポンスが {"url":"...","expiresIn":300} という単純な構造なので、軽量ライブラリで十分対応できます。
📥 署名付きURLでファイルダウンロード
bool WinHttpClient::DownloadFile(const std::string& presignedUrl, const std::wstring& outputPath) {
auto components = ParseUrl(presignedUrl);
auto response = HttpGet(components->host, components->path, components->port, components->isHttps);
std::ofstream file(outputPath, std::ios::binary);
file.write(response->data(), response->size());
return true;
}
署名付きURLにはクエリパラメータとして認証情報が含まれているので、追加のヘッダなしで単純にGETするだけでファイルが取得できます。
⚙️ config.h(設定値)
namespace Config {
// API Gateway エンドポイント(デプロイ後の値に差し替え)
constexpr const wchar_t* API_HOST = L"<your-api-id>.execute-api.ap-northeast-1.amazonaws.com";
constexpr const wchar_t* API_PATH = L"/v1/presigned-url";
constexpr int API_PORT = 443;
// API キー(デプロイ後に取得した値に差し替え)
constexpr const wchar_t* API_KEY = L"<your-api-key>";
// 取得対象の S3 オブジェクトキー
constexpr const wchar_t* TARGET_KEY = L"myapp/update/latest.zip";
} // namespace Config
🔒 セキュリティ面で気をつけたこと
| 対策 | 理由 |
|---|---|
Lambda に s3:GetObject のみ |
署名付きURLが漏洩してもS3への書き込み不可 |
| 署名付きURLの有効期限5分 | URLが漏洩しても5分で無効化 |
| APIキーのスロットリング | キー漏洩時の大量アクセス防止 |
| プレフィックス制限 | バケット内の他ファイルへのアクセス防止 |
| APIキーの定期ローテーション | 漏洩リスクの時間的限定 |
⚠️ APIキーのハードコードについて
APIキーをC++バイナリにハードコードする方式は、完全なセキュリティを保証するものではありません。C++バイナリはC#(IL)と比較してリバースエンジニアリングの難易度は高いですが、十分なスキルがあれば抽出は可能です。constexpr リテラルはコンパイラ最適化によりバイナリに平文で残るため、難読化ツールの適用も検討に値します。
そのため、APIキー漏洩を前提とした多層防御を組み合わせています:
- スロットリング: 10 req/s + 月間10,000リクエスト上限で大量アクセスを遮断
- 署名付きURLの短い有効期限: 5分で失効するため、URLの再利用は困難
- 定期的なキーローテーション: 漏洩リスクを時間的に限定
- GetObject権限のみ: 仮に全フローが露出しても書き込みは不可能
🚀 実行結果
=== Self-Update Client (Sample) ===
[OK] WinHTTP initialized
[INFO] Requesting pre-signed URL from API Gateway...
API Response: {"url":"https://<bucket-name>.s3.ap-northeast-1.amazonaws.com/...","expiresIn":300}
[OK] Pre-signed URL obtained
[INFO] Downloading file from S3...
Downloaded 66 bytes
[OK] File downloaded successfully: .\downloaded_test.txt
=== Done ===
S3に置いた test.txt が正常にダウンロードできました。
👍 この構成にしてよかった点
- クライアント側の実装がシンプル: WinHTTPでGETするだけ。AWS SDKの依存なし
- プロキシ環境で安定動作: OS標準のプロキシ解決がそのまま使える
- S3への書き込みが物理的に不可能: Lambda実行ロールにWrite権限がないので、仮に全フローが露出しても安全
- コストほぼゼロ: 月間数千リクエスト程度の利用であれば無料利用枠内に収まる
📊 他の方式との比較
署名付きURLの発行方法は Lambda 以外にもいくつかあります。要件に応じて検討してみてください。
| 方式 | 概要 | メリット | デメリット | 向いているケース |
|---|---|---|---|---|
| API Gateway + Lambda(本記事) | Lambdaで署名付きURLを生成し返却 | クライアントにAWS依存なし。プロキシ環境に強い。きめ細かいアクセス制御が可能 | Lambda + API Gatewayの管理が必要 | ネイティブアプリ、企業ネットワーク環境 |
| CloudFront + OAC | CloudFront経由でS3にアクセス。OAC(Origin Access Control)でS3を非公開のまま配信 | CDNキャッシュで高速。署名付きURLの自前生成が不要。大量配信に強い | CloudFrontの設定が必要。きめ細かいファイル単位の認可は工夫が要る | 更新ファイルの配信頻度が高い、グローバル配信 |
| CloudFront署名付きURL/Cookie | CloudFrontの信頼されたキーグループで署名付きURLまたはCookieを発行 | ワイルドカードパスで複数ファイルを一括認可可能。CDNキャッシュも活用できる | キーペア管理が必要。Lambdaより設定が複雑 | 複数ファイルを一括ダウンロードするケース |
| S3 + IAMユーザー直接アクセス | クライアントにIAMアクセスキーを持たせてAWS SDKでS3に直接アクセス | 中間サービス不要。S3のIAMポリシーで細かく制御可能 | クライアントにAWS SDK必須。キー漏洩リスク大。プロキシ対応が困難 | サーバーサイドアプリ、AWS環境内の通信 |
| Cognito + API Gateway | Cognitoでユーザー認証し、認証済みトークンでAPI Gateway経由でアクセス | ユーザー単位の認証・認可が可能。APIキーのハードコード不要 | Cognitoのユーザー管理が必要。クライアント実装がやや複雑 | ユーザーごとにアクセス制御したいケース |
本記事で採用した API Gateway + Lambda 方式は、「クライアント側の実装を最小限にしたい」「企業プロキシ環境で安定動作させたい」という要件に最もフィットします。一方、配信規模が大きい場合はCloudFront系の方式、ユーザー認証が必要な場合はCognito方式も検討する価値があります。
📝 まとめ
「S3を非公開にしたいけどAWS SDKは使いたくない」というケースで、API Gateway + Lambda + 署名付きURLの構成は結構使えます。
特にWindowsネイティブアプリでプロキシ環境を考慮する必要がある場合、WinHTTPの自動プロキシ解決に乗っかれるのが大きいです。AWS SDKを入れた瞬間にプロキシ設定の二重管理が発生するので、それを回避できるだけでも運用が楽になります。
CDK側もimport含めて100行弱で書けるので、最小構成から始めたい場合は試してみてください。
注意事項
本ブログに掲載している内容は、私個人の見解であり、所属する組織の立場や戦略、意見を代表するものではありません。あくまでエンジニアとしての経験や考えを発信していますので、ご了承ください。