本番利用で重要AdCP は金銭を伴い、機微なキャンペーンデータを扱う可能性があります。実際の広告予算を扱う実装では、本書のセキュリティ対策を必ず実装しなければなりません。
AdCP は次のような高リスク環境で動作する:
- 金銭取引: 実際の広告費が動く
- 複数主体の信頼: Principal・パブリッシャー・オーケストレーター間の連携が必要
- 機微なデータ: 1P シグナル、未公開クリエイティブ、競合ターゲティング戦略
- 非同期オペレーション: 複数システム・プロトコルにまたがる
リスク分類
高リスク(金銭)
実際の広告予算をコミットするオペレーション:
| Operation | Risk | Primary Threat |
|---|
create_media_buy | Creates financial commitments | Budget fraud, credential theft |
update_media_buy | Modifies budgets and campaign parameters | Unauthorized modifications |
要件:
- 短命な認証情報(15 分以内で失効するトークン)
- トランザクション整合性のための署名
- 大規模予算向けの多要素認証または承認フロー
- 改ざん不可能なログによる完全な監査証跡
中リスク(データアクセス)
機微なビジネスデータへアクセスするオペレーション:
| Operation | Risk |
|---|
get_media_buy_delivery | Exposes performance metrics and spend data |
list_creatives | Access to creative assets |
sync_creatives | Uploads potentially sensitive creative content |
低リスク(ディスカバリー)
公開可能なオペレーション:
| Operation | Risk |
|---|
get_adcp_capabilities | Agent capability discovery |
get_products | Public inventory discovery |
list_creative_formats | Public format catalog |
Webhook Security
Webhook の署名検証とリプレイ防止は Push Notifications で定義されています。規範的な要件:
- アルゴリズム: HMAC-SHA256 のみ
- 署名対象メッセージ:
{unix_timestamp}.{raw_http_body_bytes} — JSON を再シリアライズしてはなりません
- タイミングセーフ比較: 定数時間比較を使用しなければなりません(例:
timingSafeEqual)
- リプレイウィンドウ:
|current_time - timestamp| > 300 秒のリクエストは拒否します
- シークレットの最小長: 32 バイト
検証順序
無効なリクエストの計算コストを最小化するため、この順序で検証する:
X-ADCP-Signature または X-ADCP-Timestamp ヘッダーが欠けている場合は拒否します
- タイムスタンプが数値でない場合は拒否します
- タイムスタンプが 5 分のウィンドウ外の場合は拒否します
- HMAC を計算して比較します
シークレットローテーション
- 受信側はローテーション中に現在と前のシークレット両方からの署名を受け入れなければなりません
- ローテーションウィンドウはリプレイウィンドウ(5 分)を超えるべきではありません
- パブリッシャーはローテーション後すぐに新しいシークレットで署名を開始します
Webhook URL バリデーション(SSRF)
バイヤーが提供する push_notification_config.url は SSRF のベクターとなります。パブリッシャーはしなければなりません:
- HTTPS 以外の URL を拒否します
- プライベート/予約済み IP レンジ(RFC 1918、RFC 6598、リンクローカル)をターゲットにする URL を拒否します
- 接続前に DNS を解決して、解決された IP がプライベートでないことを検証します
認証のベストプラクティス
認証情報の保管
// Use secure key management systems
// Never commit credentials to version control
// Use environment variables or secret managers
// Example: Secure credential retrieval
async function getCredentials(principalId) {
// Retrieve from secure storage (AWS KMS, Vault, etc.)
const encrypted = await secretManager.get(`principal/${principalId}/apiKey`);
return decrypt(encrypted);
}
トークンの有効期限
高リスクオペレーションには短命なトークンを使用します:
const TOKEN_LIFETIMES = {
discovery: 3600, // 1 hour for read operations
financial: 900, // 15 minutes for financial operations
refresh: 86400 // 24 hours for refresh tokens
};
function validateToken(token, operationType) {
const decoded = jwt.verify(token, secret);
const maxAge = TOKEN_LIFETIMES[operationType] || TOKEN_LIFETIMES.discovery;
if (Date.now() - decoded.iat > maxAge * 1000) {
throw new Error('Token expired for this operation type');
}
return decoded;
}
Principal Isolation
マルチテナントセキュリティ
複数の Principal を管理するオーケストレーターは厳格な分離を強制しなければなりません:
// ALWAYS filter by principal_id - never query without it
async function getMediaBuy(mediaBuyId, principalId) {
const mediaBuy = await db.mediaBuys.findOne({
id: mediaBuyId,
principal_id: principalId // Critical: prevents cross-principal access
});
if (!mediaBuy) {
// Generic error - don't reveal if campaign exists for another principal
throw new NotFoundError("Media buy not found");
}
return mediaBuy;
}
行レベルセキュリティ
データベースに行レベルセキュリティを実装します:
-- PostgreSQL example
CREATE POLICY principal_isolation ON media_buys
USING (principal_id = current_setting('app.current_principal')::uuid);
ALTER TABLE media_buys ENABLE ROW LEVEL SECURITY;
金融トランザクションの安全性
冪等性
状態を変更するすべてのオペレーションが冪等性をサポートしなければなりません:
async function createMediaBuy(request) {
const { idempotency_key } = request;
// Check if this idempotency key was already processed
const existing = await db.findByIdempotencyKey(idempotency_key);
if (existing) {
// Return existing result, don't charge again
return existing;
}
// Process new request atomically
return db.transaction(async (tx) => {
const result = await processMediaBuy(tx, request);
await tx.idempotencyKeys.insert({ key: idempotency_key, result });
return result;
});
}
バイヤーは (seller, request) ペアごとに一意の idempotency_key を生成しなければなりません。同じキーを複数のセラーに再利用すると、共謀するセラーが同一バイヤーのリクエストを相関づけることができます。リクエストごとに新しい UUID v4 を使用すること。
Governance Context
governance_context はガバナンスエージェントからバイヤー、セラー、そして戻りという信頼境界を越える不透明な文字列です。実装者は各ホップでこれを信頼されない入力として扱うべきだ:
- ガバナンスエージェントは
governance_context をサーバーサイドの状態へのルックアップキーまたは署名済みトークン(例: HMAC-SHA256)として扱わなければなりません。値に平文の状態をエンコードすると、仲介者による改ざんを許す。状態を直接エンコードする場合は、変更を検出できるよう署名しなければなりません。
- ガバナンスエージェントは各
governance_context を特定の (plan_id, media_buy_id) タプルに紐付け、リクエストの識別子と一致しないコンテキストを拒否すべきです。これにより、あるメディアバイのコンテキストを別のメディアバイにリプレイすることを防ぐ。
- セラーは
governance_context を解釈または変更してはなりません。受け取ったまま保存し、その後のすべてのガバナンス呼び出しにそのまま含めます。
- バイヤーはガバナンスエージェントから
governance_context を受け取り、プロトコルエンベロープに添付します。値を構築または変更してはなりません。
予算バリデーション
コミット前に予算を検証する:
async function validateBudget(request, principal) {
const { budget } = request;
// Check positive amount
if (budget.amount <= 0) {
throw new ValidationError('Budget must be positive');
}
// Check against account limits
const limits = await getAccountLimits(principal.id);
if (budget.amount > limits.daily_spend_limit) {
throw new BudgetError('Exceeds daily spend limit');
}
// Check available balance
const balance = await getAvailableBalance(principal.id);
if (budget.amount > balance) {
throw new BudgetError('Insufficient balance');
}
}
トランスポートセキュリティ
HTTPS 要件
- すべての AdCP 通信で HTTPS(TLS 1.3+、最低 TLS 1.2)を使用しなければなりません
- SSL 証明書を検証する(本番で自己署名は不可)
- HTTP Strict Transport Security (HSTS) ヘッダーを実装します
app.use((req, res, next) => {
// HSTS header
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
// Prevent clickjacking
res.setHeader('X-Frame-Options', 'DENY');
// Prevent MIME sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
next();
});
入力バリデーション
リクエストバリデーション
ユーザー入力はすべて検証する:
const INPUT_LIMITS = {
targeting_brief_max_length: 5000,
creative_upload_max_size: 100 * 1024 * 1024, // 100MB
max_formats_per_request: 50,
max_products_per_query: 100
};
function validateRequest(request) {
// Check string lengths
if (request.brief?.length > INPUT_LIMITS.targeting_brief_max_length) {
throw new ValidationError('Brief exceeds maximum length');
}
// Validate IDs are proper UUIDs
if (request.product_id && !isValidUUID(request.product_id)) {
throw new ValidationError('Invalid product_id format');
}
// Reject unexpected fields
const allowedFields = ['brief', 'product_id', 'budget', 'context_id'];
for (const field of Object.keys(request)) {
if (!allowedFields.includes(field)) {
throw new ValidationError(`Unexpected field: ${field}`);
}
}
}
SQL インジェクション防止
常にパラメタライズドクエリを使用します:
// GOOD: Parameterized query
const result = await db.query(
'SELECT * FROM media_buys WHERE id = $1 AND principal_id = $2',
[mediaBuyId, principalId]
);
// BAD: String concatenation (NEVER do this)
// const result = await db.query(
// `SELECT * FROM media_buys WHERE id = '${mediaBuyId}'`
// );
監査ログ
ログに必須のイベント
セキュリティ関連イベントをすべて記録する:
const LOG_EVENTS = {
AUTH_SUCCESS: 'auth_success',
AUTH_FAILURE: 'auth_failure',
BUDGET_COMMIT: 'budget_commit',
BUDGET_MODIFY: 'budget_modify',
ACCESS_DENIED: 'access_denied',
WEBHOOK_VERIFIED: 'webhook_verified',
WEBHOOK_REJECTED: 'webhook_rejected'
};
function logSecurityEvent(eventType, details) {
console.log(JSON.stringify({
event: eventType,
timestamp: new Date().toISOString(),
principal_id: details.principalId,
ip_address: details.ipAddress,
resource: details.resource,
outcome: details.outcome,
// NEVER log: credentials, PII, targeting briefs
}));
}
ログ保存期間
- セキュリティログ: 最低 90 日(推奨 365 日)
- 財務ログ: 7 年(コンプライアンス要件)
- アクセスログ: 最低 30 日
セキュリティチェックリスト
パブリッシャー(AdCP サーバー)
プリンシパル(AdCP クライアント)
オーケストレーター(マルチプリンシパルエージェント)
次のステップ