Skip to content

Commit a4b5b90

Browse files
Merge pull request #1102 from smalruby/feature/admin-dos-hardening
fix(admin): 公開ソース前提の DoS・コスト防御を強化 (prod JWT authorizer + throttle)
2 parents 61331f2 + 1addc91 commit a4b5b90

3 files changed

Lines changed: 65 additions & 50 deletions

File tree

.claude/rules/infra/smalruby-admin.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ CDK project for the Admin service (API Gateway + Lambda + DynamoDB) — 管理 S
1818
- **全変更操作に `audit()`**(構造化ログ)。prod の LogGroup retention は **ONE_YEAR**(艦隊標準の ONE_MONTH からの意図的逸脱 — 監査記録のため)
1919
- エラー規約は bug-report 準拠: 認証失敗 401 / 認可失敗 **403**(classroom の 401 とは異なる)
2020

21+
## DoS / コスト防御(ソース公開前提の脅威モデル)
22+
23+
ソースは公開されるため攻撃者は既知エンドポイント・ルート・env 変数名を把握している前提で設計する。
24+
25+
- **prod は API Gateway の JWT authorizer**(issuer `https://accounts.google.com` / audience = `ADMIN_GOOGLE_CLIENT_ID`)で、正当な署名トークンでない要求を **Lambda 到達前に 401** で弾く。認証なしフラッドで Lambda 起動・Lambda ログ ingestion 課金を発生させない。**stg は dev bypass(非 JWT)を使う E2E のため authorizer を付けない**(この stage 差を消さない)。Lambda 側の fail-closed 認可は両 stage で維持(多層)
26+
- **スロットルは単一運用者向けに絞る**(rate 5 / burst 10)。緩めない
27+
- **X-Ray(トレーシング)を有効化しない****API Gateway アクセスログを有効化しない**(コスト源。現状 Lambda は全て PassThrough)
28+
- Lambda ログは監査行と 500 のみ。**401/403/404/400 パスにアプリログを足さない**(DoS 時のログ ingestion 課金を防ぐ)
29+
- `dev-admin@example.com`**stg の allowlist のみ**。prod に登録しない
30+
2131
## cross-service アクセス
2232

2333
管理対象(classroom / shared-assignments のテーブル・バケット)へは**ステージ別の名前規約**で ARN を構築して grant する。**classroom スタック側に変更を加えない**(N2)。バグ報告ドメインは既存 bug-report API を SPA から直接呼ぶ(本スタックは関与しない)。

docs/admin/operations.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,18 @@
2222

2323
- **admin 専用 Google OAuth Client ID**(決定 B): エディタの `GOOGLE_CLIENT_ID` とは別に GCP コンソールで作成し、`.env.prod` / `.env.stg``ADMIN_GOOGLE_CLIENT_ID` に設定する(**prod は未設定だとデプロイが落ちる**ガードあり)
2424
- 承認済み JavaScript 生成元: `https://smalruby.app`(+ stg 用に `http://localhost:8602`
25-
- stg のみ `DEV_BYPASS_TOKEN` による自動テスト用バイパスあり(prod 設定はデプロイ時に throw。バイパス identity `dev-admin@example.com`**allowlist 登録が必要**
25+
- stg のみ `DEV_BYPASS_TOKEN` による自動テスト用バイパスあり(prod 設定はデプロイ時に throw。バイパス identity `dev-admin@example.com`**allowlist 登録が必要**)。**`dev-admin@example.com` は stg の allowlist にのみ登録し、prod には絶対に登録しない**(prod ではバイパスが無効なので実害はないが、`example.com` は IANA 予約で verified email を取得できないため無意味かつ紛らわしい)
26+
27+
## セキュリティ・コスト方針(ソース公開前提の脅威モデル)
28+
29+
Admin を含む Smalruby のソースは公開されるため、攻撃者は既知のエンドポイント(`admin.api.smalruby.app`)とルート・env 変数名を把握して攻撃してくる前提で設計している。
30+
31+
- **多層の認可(fail-closed)**: すべてのルートで ① Google 署名 + `aud=ADMIN_GOOGLE_CLIENT_ID` 検証 → ② `SmalrubyAdmins` 許可リスト(deny-by-default)→ ③ sub 固定。空クライアント ID は全拒否、未登録 email は 403、email 一致でも sub 不一致は 403。DynamoDB / S3 は非公開(S3 Block Public Access、アクセスは Lambda の IAM ロールか短命 presigned URL のみ)
32+
- **prod のゲートレベル JWT authorizer**: prod は API Gateway の JWT authorizer(issuer `https://accounts.google.com` / audience = admin Client ID)で、**正当な署名トークンでない要求を Lambda 到達前に 401 で弾く**。→ 認証なしの DoS フラッドは Lambda 起動も Lambda ログ ingestion も発生させられない(費用がかからない)。stg は dev bypass(JWT ではない)を使う E2E のため authorizer なし(Lambda 側の同じ fail-closed 認可のみ)
33+
- ⚠️ prod デプロイ後の初回ログインで **必ず疎通確認**する。Google ID トークンの `iss` が万一 `accounts.google.com``https://` 無し)だと authorizer が弾くため、ログインできなければ authorizer の issuer 設定を疑う(現行トークンは `https://accounts.google.com`
34+
- **スロットリング**: 単一運用者ツールなのでレート 5 / バースト 10 に絞り、攻撃者が積み上げられる API Gateway リクエスト課金の上限を抑える(人間の操作は毎秒数回で十分)
35+
- **追加コスト源を持たない**: X-Ray(トレーシング)不使用・API Gateway アクセスログ無効。Lambda ログは監査行と 500 のみ(401/403 はアプリログを出さない)。retention は prod 1 年(監査目的・低volume)/ stg 1 週間
36+
- **CloudWatch 費用**: Admin のログ量は極小。艦隊全体の CloudWatch 無料枠超過は mesh-v2 の prod AppSync ログ(無期限保持)が主因であり Admin とは別問題(mesh-v2 側で retention と field log level を見直す)
2637

2738
## 監査ログ
2839

infra/smalruby-admin/lib/admin-stack.ts

Lines changed: 43 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as lambda from 'aws-cdk-lib/aws-lambda';
55
import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs';
66
import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
77
import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
8+
import * as apigatewayv2Authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
89
import * as logs from 'aws-cdk-lib/aws-logs';
910
import * as route53 from 'aws-cdk-lib/aws-route53';
1011
import * as route53Targets from 'aws-cdk-lib/aws-route53-targets';
@@ -230,63 +231,56 @@ export class SmalrubyAdminStack extends cdk.Stack {
230231
handlerFn,
231232
);
232233

233-
this.api.addRoutes({
234-
path: '/admin/me',
235-
methods: [apigatewayv2.HttpMethod.GET],
236-
integration,
237-
});
234+
// DoS cost defense (source is public — attackers hammer the known
235+
// endpoint). In prod, a gateway-level Google JWT authorizer rejects any
236+
// request without a validly-signed token for our admin audience BEFORE
237+
// the Lambda runs, so an unauthenticated flood cannot burn Lambda
238+
// invocations or Lambda log ingestion — only (throttled) API-GW requests.
239+
// The Lambda still re-verifies + checks the allowlist (defense in depth).
240+
//
241+
// stg keeps NO authorizer so the dev-bypass token (not a JWT) still works
242+
// for automated E2E. stg is the same fail-closed Lambda auth, just without
243+
// the extra gateway filter.
244+
const authorizer = (stage === 'prod' && adminGoogleClientId)
245+
? new apigatewayv2Authorizers.HttpJwtAuthorizer(
246+
'AdminJwtAuthorizer',
247+
'https://accounts.google.com',
248+
{
249+
identitySource: ['$request.header.Authorization'],
250+
jwtAudience: [adminGoogleClientId],
251+
},
252+
)
253+
: undefined;
254+
255+
const addRoute = (routePath: string, methods: apigatewayv2.HttpMethod[]) => {
256+
this.api.addRoutes({ path: routePath, methods, integration, authorizer });
257+
};
258+
259+
addRoute('/admin/me', [apigatewayv2.HttpMethod.GET]);
238260

239261
// みんなの課題 moderation (S3 #1083)
240-
this.api.addRoutes({
241-
path: '/admin/shared-assignments',
242-
methods: [apigatewayv2.HttpMethod.GET],
243-
integration,
244-
});
245-
this.api.addRoutes({
246-
path: '/admin/shared-assignments/reports',
247-
methods: [apigatewayv2.HttpMethod.GET],
248-
integration,
249-
});
250-
this.api.addRoutes({
251-
path: '/admin/shared-assignments/{sharedId}',
252-
methods: [apigatewayv2.HttpMethod.GET, apigatewayv2.HttpMethod.PATCH],
253-
integration,
254-
});
262+
addRoute('/admin/shared-assignments', [apigatewayv2.HttpMethod.GET]);
263+
addRoute('/admin/shared-assignments/reports', [apigatewayv2.HttpMethod.GET]);
264+
addRoute('/admin/shared-assignments/{sharedId}',
265+
[apigatewayv2.HttpMethod.GET, apigatewayv2.HttpMethod.PATCH]);
255266

256267
// Classroom management + expired restore (S4 #1084). HTTP API prefers
257268
// the literal restore-candidates route over {classroomId} by specificity.
258-
this.api.addRoutes({
259-
path: '/admin/classrooms',
260-
methods: [apigatewayv2.HttpMethod.GET],
261-
integration,
262-
});
263-
this.api.addRoutes({
264-
path: '/admin/classrooms/restore-candidates',
265-
methods: [apigatewayv2.HttpMethod.GET],
266-
integration,
267-
});
268-
this.api.addRoutes({
269-
path: '/admin/classrooms/{classroomId}',
270-
methods: [apigatewayv2.HttpMethod.GET, apigatewayv2.HttpMethod.PATCH],
271-
integration,
272-
});
273-
this.api.addRoutes({
274-
path: '/admin/classrooms/{classroomId}/restore-plan',
275-
methods: [apigatewayv2.HttpMethod.GET],
276-
integration,
277-
});
278-
this.api.addRoutes({
279-
path: '/admin/classrooms/{classroomId}/restore',
280-
methods: [apigatewayv2.HttpMethod.POST],
281-
integration,
282-
});
283-
284-
// Single-operator tool: keep throttling tight.
269+
addRoute('/admin/classrooms', [apigatewayv2.HttpMethod.GET]);
270+
addRoute('/admin/classrooms/restore-candidates', [apigatewayv2.HttpMethod.GET]);
271+
addRoute('/admin/classrooms/{classroomId}',
272+
[apigatewayv2.HttpMethod.GET, apigatewayv2.HttpMethod.PATCH]);
273+
addRoute('/admin/classrooms/{classroomId}/restore-plan', [apigatewayv2.HttpMethod.GET]);
274+
addRoute('/admin/classrooms/{classroomId}/restore', [apigatewayv2.HttpMethod.POST]);
275+
276+
// Single-operator tool: a human never needs more than a couple of
277+
// requests per second, so throttle hard to cap the API-GW request bill an
278+
// attacker can run up (the JWT authorizer already caps Lambda/log cost).
285279
const defaultStage = this.api.defaultStage?.node.defaultChild as apigatewayv2.CfnStage;
286280
if (defaultStage) {
287281
defaultStage.defaultRouteSettings = {
288-
throttlingRateLimit: 10,
289-
throttlingBurstLimit: 20,
282+
throttlingRateLimit: 5,
283+
throttlingBurstLimit: 10,
290284
};
291285
}
292286

0 commit comments

Comments
 (0)