canister-security について
Internet Computer の canister を開発する Motoko・Rust 開発者向けのセキュリティパターンです。アクセス制御、非同期呼び出しの TOCTOU、再入、コールバックのトラップなどへの実装例を扱います。
主な機能
- 01アクセス制御と匿名 Principal の確認
- 02CallerGuard による同時操作ロック
- 03非同期状態とコールバックトラップの処理
- 04アップグレード・cycles・ストレージのリスク解説
- 05HTTPS ルートキーの安全な扱い
活用シーン
- 01canister の update メソッドに認可チェックを追加
- 02canister 間の金融処理を保護
- 03コールバックのトラップによる状態不整合を防止
- 04デプロイやアップグレード前のリスク確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dfinity/ icskills --skill canister-securityインストール後、Claude Code で/canister-securityを入力して読み込まれたか確認します。
詳細
Canister のセキュリティ
これは何ですか
Motoko と Rust における IC canister のセキュリティパターンです。非同期メッセージングモデルでは、await の呼び出し間に状態が変化する TOCTOU(チェック時と使用時の不一致)脆弱性が発生します。canister_inspect_message は信頼できるセキュリティ境界ではありません。インターネット上の誰もが update 呼び出しを送信して、あなたの cycles を消費させることができます。このスキルでは、アクセス制御、再入防止、非同期処理の安全性、コールバックのトラップ処理について、コピーしてそのまま使える正しいパターンを提供します。
前提条件
- Motoko の場合:
mopsパッケージマネージャー、mops.toml のcore = "2.0.0" - Rust の場合:
ic-cdk = "0.19"、candid = "0.10"
セキュリティ上の落とし穴
-
アクセス制御を
canister_inspect_messageに依存すること。 このフックは、完全なコンセンサスを伴わずに単一のレプリカで実行されます。そのレプリカが悪意のあるものであれば、チェックを単にスキップし、メッセージを検査せずに update 呼び出しを実行できます。また、canister 間呼び出し、query 呼び出し、management canister 呼び出しでは一切呼び出されません。すべての update メソッド内で、必ずアクセスチェックを重複して行ってください。inspect_messageは cycles を節約するための最適化としてのみ使用し、セキュリティ境界としては使用しないでください。 -
匿名 principal を拒否し忘れること。 認証を必要とするすべてのエンドポイントでは、呼び出し元が匿名 principal(
2vxsx-fae)ではないことを確認する必要があります。Motoko ではPrincipal.isAnonymous(caller)を使用し、Rust ではmsg_caller() != Principal::anonymous()と比較してください。これを行わないと、認証されていない呼び出し元が保護対象のメソッドを呼び出せます。また、canister が呼び出し元の principal を ID キーとして使用している場合(残高の管理など)、匿名 principal が誰でも使える共有 ID になってしまいます。 -
非同期呼び出しの前に状態を読み取り、呼び出し後も変化していないと思い込むこと(TOCTOU)。 canister が canister 間呼び出しを
awaitしている間に、ほかのメッセージが割り込み、状態を変更する可能性があります。これは IC 上の DeFi エクスプロイトの最も重大な原因のひとつです。並行する操作を防ぐには、呼び出し元ごとのロック(CallerGuard パターン)を使用してください。金融操作では、サーガパターン(awaitの前に差し引き、失敗時に補償する)も検討してください。ただし、コールバックのトラップや、結果が曖昧になる呼び出しタイムアウトなどのエッジケースがあるため、正しく実装するのは複雑です。 -
pre_upgradeでトラップすること。pre_upgradeがトラップすると(たとえば、シリアライズするデータが多すぎて命令数の上限を超えると)、canister は永久にアップグレードできなくなります。アップグレード時にシリアライズする必要がある大きなデータ構造をヒープに保存するのは避けてください。Rust では、安定メモリに直接アクセスするためにic-stable-structuresを使用してください。Motoko では、persistent actor宣言により、すべてのletとvarの変数が自動的に安定メモリに保存されるため、手動でのシリアライズは不要です。 -
cycles の残高を監視しないこと。 すべての canister には、デフォルトで 2,592,000 秒(約30日)の
freezing_thresholdが設定されています。cycles がしきい値の準備金を下回ると、canister はフリーズし(すべての update 呼び出しを拒否します)、cycles がゼロになるとアンインストールされます。コードとメモリは削除されますが、canister ID とコントローラーは維持されます。本当の落とし穴は、cycles を積極的に監視して補充しないことです。価値のある状態を保持する本番環境の canister では、freezing threshold を引き上げ、自動監視を設定してください。# Check current settings (mainnet) icp canister settings show backend -e ic # Increase freezing threshold for high-value canisters icp canister settings update backend --freezing-threshold 7776000 -e ic # 90 days -
バックアップなしでコントローラーがひとつだけであること。 コントローラー ID の秘密鍵を紛失すると、canister は永久にアップグレードできなくなります。復旧手段はありません。必ずバックアップコントローラーまたはガバナンス canister を追加してください:
icp canister settings update backend --add-controller <backup-principal> -e icデプロイ時に、追加するバックアップコントローラー principal があるか開発者に確認してください。
-
ローカル開発を動作させるために
fetchRootKey()を呼び出すこと。fetchRootKey()は実行時にレプリカからルート公開鍵を取得し、返された値をそのまま信頼します。このコードパスがメインネットで実行されると、中間者が別の鍵に差し替えて、すべての検証を破る可能性があります。環境チェックの背後であっても、決して呼び出さないでください。環境による分岐こそが、この呼び出しを本番ビルドに紛れ込ませる原因です。代わりに、ic_envCookie(@icp-sdk/core/agent/canister-envのsafeGetCanisterEnv()経由のIC_ROOT_KEY)からルート鍵を読み取り、HttpAgent.create()のrootKeyオプションとして渡してください。この Cookie は、静的サイト/アセットキャニスターのフロントエンドと icp-cli の Vite 開発サーバーによって自動的に設定されるため、環境分岐なしで同じコードをローカルと本番環境の両方で使用できます。Cookie には常にネットワークのルート鍵が含まれており、メインネットではこの鍵はエージェントにハードコードされた鍵と同一なので、渡しても何も起こりません。完全なパターンについては、icp-cli スキルのreferences/binding-generation.mdを参照してください。 -
ガードなしで管理者メソッドを公開すること。 すべての更新メソッドは、インターネット上の誰でも呼び出せます。管理者メソッド(移行、設定、ミント)では、呼び出し元が許可リストに含まれているかを明示的に確認する必要があります。組み込みのロールシステムはありません。自分で実装する必要があります。管理者権限の取り消しも必ず実装してください。取り消し機能の欠如は、バグのよくある原因です。
-
キャニスターの状態に秘密情報を保存すること。 標準のアプリケーションサブネット上では、ノードオペレーターがキャニスターのメモリを読み取れます。秘密鍵、API シークレット、パスワードをキャニスターの状態に保存しないでください。オンチェーンでの秘密情報管理には、vetKD(しきい値鍵導出)を使用してください。
-
ユーザーが制御できるストレージを無制限に許可すること。 ユーザーが制限なくデータを保存できる場合、攻撃者が 4 GiB の Wasm ヒープまたは安定メモリを埋め尽くし、キャニスターを機能停止させる可能性があります。必ずユーザーごとのストレージクォータを適用し、入力サイズを検証してください。
-
状態を変更した後のコールバックでトラップが発生すること。 キャニスターがキャニスター間呼び出しの前に状態を変更し、そのコールバックでトラップが発生すると、呼び出し前の変更は保持されますが、コールバック内の変更はロールバックされます。悪意のある呼び出し先はこれを悪用し、アカウントの引き落としなど、セキュリティ上重要な処理を回避できます。重要な状態変更は非同期境界の前に行い、失敗またはトラップが発生した場合に正しくロールバックされるよう、コードを構成してください。クリーンアップが必ず実行されるよう、
try/finally(Motoko)またはDropガード(Rust)を使用してください。クリーンアップコードは最小限にしてください。クリーンアップ中のトラップも同じ問題を引き起こします。ロールバック処理にはcall_on_cleanupの使用を検討し、クラッシュに耐える状態遷移にはジャーナリングを検討してください。 -
アップグレードを妨げる無制限の wait 呼び出し。 応答しない信頼できない、または不具合のある呼び出し先をキャニスターが呼び出すと、未完了の応答を待っている間、キャニスターを停止できず、そのためアップグレードもできません。呼び出し先の動作にかかわらず呼び出しが有限時間内に完了するよう、タイムアウト付きの有界 wait 呼び出しを使用してください。
仕組み
IC セキュリティモデル
- 更新呼び出しはコンセンサスを経由します。サブネット上のすべてのノードがコードを実行し、結果について合意する必要があります。標準的なアプリケーションサブネットは13ノード、システムサブネットと受託者サブネットはそれ以上(28以上)です。これにより更新呼び出しは改ざんできなくなりますが、時間がかかります(約2秒)。
- クエリ呼び出しは単一のレプリカで実行されます。高速(約200ミリ秒)ですが、レプリカが不正確または悪意のある結果を返す可能性があります。レプリカ署名付きクエリは部分的な緩和策になります(応答するレプリカが応答に署名します)が、完全に信頼するには、セキュリティ上重要な読み取りに認証済みデータまたは更新呼び出しを使用してください。
- キャニスター間呼び出しは非同期メッセージです。リクエストを送信してから応答を受け取るまでの間、キャニスターは他のメッセージを処理できます。その間に状態が変わる可能性があります(前述のTOCTOUの落とし穴を参照)。
- トラップ時の状態ロールバック。 メッセージの実行がトラップすると、その状態変更はすべてロールバックされます。キャニスター間呼び出しでは、最初の実行(
awaitの前)とコールバック(awaitの後)は別々のメッセージです。コールバックでトラップが発生すると、ロールバックされるのはコールバックの変更のみで、最初の実行による変更は保持されます。そのため、ロックの解放などのクリーンアップ処理は通常のコールバックコードではなく、クリーンアップコンテキスト(finally/Drop)に記述する必要があります。
実装
Motoko
アクセス制御
shared(msg)パターンを使用してデプロイヤーをアトミックに捕捉するため、別途init()を呼び出す必要がなく、フロントランニングのリスクもありません。
import Principal "mo:core/Principal";
import Set "mo:core/pure/Set";
import Runtime "mo:core/Runtime";
shared(msg) persistent actor class MyCanister() {
// --- Authorization state ---
// transient: recomputed on each install/upgrade from msg.caller (the controller)
transient let owner = msg.caller;
var admins : Set.Set<Principal> = Set.empty();
// --- Guards ---
func requireAuthenticated(caller : Principal) {
if (Principal.isAnonymous(caller)) {
Runtime.trap("anonymous caller not allowed");
};
};
func requireOwner(caller : Principal) {
requireAuthenticated(caller);
if (caller != owner) {
Runtime.trap("caller is not the owner");
};
};
func requireAdmin(caller : Principal) {
requireAuthenticated(caller);
if (caller != owner and not Set.contains(admins, Principal.compare, caller)) {
Runtime.trap("caller is not an admin");
};
};
// --- Admin management ---
public shared ({ caller }) func addAdmin(newAdmin : Principal) : async () {
requireOwner(caller);
admins := Set.add(admins, Principal.compare, newAdmin);
};
public shared ({ caller }) func removeAdmin(admin : Principal) : async () {
requireOwner(caller);
admins := Set.remove(admins, Principal.compare, admin);
};
// --- Endpoints ---
public shared ({ caller }) func publicAction() : async Text {
requireAuthenticated(caller);
"ok";
};
public shared ({ caller }) func adminAction() : async () {
requireAdmin(caller);
// ... protected logic
};
};
再入可能性の防止(CallerGuardパターン)
呼び出し元ごとのロックにより、最初の呼び出しが応答を待っている間に、同じ呼び出し元から2回目の呼び出しが行われるのを防ぎます。ガードはfinallyブロックで解放する必要があります。コールバックでトラップすると、catch内の状態変更はロールバックされますが、クリーンアップコンテキストではfinallyが実行され、状態変更が保持されます。
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Error "mo:core/Error";
import Result "mo:core/Result";
// Inside the persistent actor class { ... }
// otherCanister is application-specific — replace with your canister reference.
let pendingRequests = Map.empty<Principal, Bool>();
func acquireGuard(principal : Principal) : Result.Result<(), Text> {
if (Map.get(pendingRequests, Principal.compare, principal) != null) {
return #err("already processing a request for this caller");
};
Map.add(pendingRequests, Principal.compare, principal, true);
#ok;
};
func releaseGuard(principal : Principal) {
ignore Map.delete(pendingRequests, Principal.compare, principal);
};
public shared ({ caller }) func doSomethingAsync() : async Result.Result<Text, Text> {
requireAuthenticated(caller);
// 1. Acquire per-caller lock — rejects concurrent calls from same principal
switch (acquireGuard(caller)) {
case (#err(msg)) { return #err(msg) };
case (#ok) {};
};
// 2. Make inter-canister call
try {
let result = await otherCanister.someMethod();
#ok(result)
} catch (e) {
#err("call failed: " # Error.message(e))
} finally {
// Runs in cleanup context even if the callback traps — changes here persist.
releaseGuard(caller);
};
};
inspect_message(サイクル最適化のみ)
// Inside persistent actor { ... }
// Method variants must match your public methods
system func inspect(
{
caller : Principal;
msg : {
#adminAction : () -> ();
#addAdmin : () -> Principal;
#removeAdmin : () -> Principal;
#publicAction : () -> ();
#doSomethingAsync : () -> ();
}
}
) : Bool {
switch (msg) {
// Admin methods: reject anonymous to save cycles on Candid decoding
case (#adminAction _) { not Principal.isAnonymous(caller) };
case (#addAdmin _) { not Principal.isAnonymous(caller) };
case (#removeAdmin _) { not Principal.isAnonymous(caller) };
case (#doSomethingAsync _) { not Principal.isAnonymous(caller) };
// Public methods: accept all
case (_) { true };
};
};
Rust
アクセス制御(CDKガードパターンを使用)
guard属性はメソッド本体の前にチェックを実行します。ガードがErrを返すと、メソッドのコードが実行される前に呼び出しが拒否されます。これはメソッド内でガード関数を呼び出すより堅牢です。追加し忘れることがありません。
use ic_cdk::{init, update};
use ic_cdk::api::msg_caller;
use candid::Principal;
use std::cell::RefCell;
thread_local! {
static OWNER: RefCell<Principal> = RefCell::new(Principal::anonymous());
static ADMINS: RefCell<Vec<Principal>> = RefCell::new(vec![]);
}
// --- Guards (for #[update(guard = "...")] attribute) ---
// Must return Result<(), String>. Err rejects the call.
fn require_authenticated() -> Result<(), String> {
if msg_caller() == Principal::anonymous() {
return Err("anonymous caller not allowed".to_string());
}
Ok(())
}
fn require_owner() -> Result<(), String> {
require_authenticated()?;
OWNER.with(|o| {
if msg_caller() != *o.borrow() {
return Err("caller is not the owner".to_string());
}
Ok(())
})
}
fn require_admin() -> Result<(), String> {
require_authenticated()?;
let caller = msg_caller();
let is_authorized = OWNER.with(|o| caller == *o.borrow())
|| ADMINS.with(|a| a.borrow().contains(&caller));
if !is_authorized {
return Err("caller is not an admin".to_string());
}
Ok(())
}
// --- Init ---
#[init]
fn init(owner: Principal) {
OWNER.with(|o| *o.borrow_mut() = owner);
}
// --- Endpoints ---
#[update(guard = "require_authenticated")]
fn public_action() -> String {
"ok".to_string()
}
#[update(guard = "require_admin")]
fn admin_action() {
// ... protected logic — guard already validated caller
}
#[update(guard = "require_owner")]
fn add_admin(new_admin: Principal) {
ADMINS.with(|a| a.borrow_mut().push(new_admin));
}
#[update(guard = "require_owner")]
fn remove_admin(admin: Principal) {
ADMINS.with(|a| a.borrow_mut().retain(|p| p != &admin));
}
ic_cdk::export_candid!();
再入可能性の防止(CallerGuardパターン)
CallerGuardはDropトレイトを使用して、ガードがスコープを抜けるときにロックを解放します。コールバックでトラップした場合も同様です(ic-cdk 0.5.1以降では、クリーンアップ中にローカル変数がスコープを抜けます)。let _ = CallerGuard::new(caller)?は絶対に使用しないでください。ガードがすぐに破棄され、ロックが機能しなくなります。
use std::cell::RefCell;
use std::collections::BTreeSet;
use candid::Principal;
use ic_cdk::update;
use ic_cdk::api::msg_caller;
use ic_cdk::call::Call;
// other_canister_id is application-specific — replace with your canister reference.
thread_local! {
static PENDING: RefCell<BTreeSet<Principal>> = RefCell::new(BTreeSet::new());
}
struct CallerGuard {
principal: Principal,
}
impl CallerGuard {
fn new(principal: Principal) -> Result<Self, String> {
PENDING.with(|p| {
if !p.borrow_mut().insert(principal) {
return Err("already processing a request for this caller".to_string());
}
Ok(Self { principal })
})
}
}
impl Drop for CallerGuard {
fn drop(&mut self) {
PENDING.with(|p| {
p.borrow_mut().remove(&self.principal);
});
}
}
#[update]
async fn do_something_async() -> Result<String, String> {
let caller = msg_caller();
if caller == Principal::anonymous() {
return Err("anonymous caller not allowed".to_string());
}
// Acquire per-caller lock — rejects concurrent calls from same principal.
// Drop releases lock even if callback traps.
let _guard = CallerGuard::new(caller)?;
// Make inter-canister call
let response = Call::bounded_wait(other_canister_id(), "some_method")
.await
.map_err(|e| format!("call failed: {:?}", e))?;
let result: String = response.candid()
.map_err(|e| format!("decode failed: {:?}", e))?;
Ok(result)
// _guard dropped here → lock released
}
inspect_message(サイクル最適化のみ)
use ic_cdk::api::{accept_message, msg_caller, msg_method_name};
use candid::Principal;
/// Pre-filter to reduce cycle waste from spam.
/// Runs on ONE node. Can be bypassed. NOT a security check.
/// Always duplicate real access control inside each method or via guard attribute.
#[ic_cdk::inspect_message]
fn inspect_message() {
let method = msg_method_name();
match method.as_str() {
// Admin methods: only accept from non-anonymous callers
"admin_action" | "add_admin" | "remove_admin" | "do_something_async" => {
if msg_caller() != Principal::anonymous() {
accept_message();
}
// Silently reject anonymous — saves cycles on Candid decoding
}
// Public methods: accept all
_ => accept_message(),
}
}