Skip to content

Commit 26ef369

Browse files
lang: Fix unexpected account substitution in InterfaceAccount (#4139)
* tests: Add basic security checks tests for `InterfaceAccount` * ci: Add `interface-account` tests * lang: Fix `InterfaceAccount::try_from` * lang: Fix `InterfaceAccount::reload` * lang: Allow multiple owners in `InterfaceAccount::reload` * lang: Remove the unnecessary clone to get the account info * lang: Fix compile error about mutable borrow * lang: Remove the unused imports
1 parent 6374139 commit 26ef369

File tree

14 files changed

+355
-47
lines changed

14 files changed

+355
-47
lines changed

.github/workflows/reusable-tests.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,8 @@ jobs:
472472
path: tests/bench
473473
- cmd: cd tests/idl && ./test.sh
474474
path: tests/idl
475+
- cmd: cd tests/interface-account && anchor test
476+
path: tests/interface-account
475477
- cmd: cd tests/lazy-account && anchor test --skip-lint
476478
path: tests/lazy-account
477479
- cmd: cd tests/test-instruction-validation && ./test.sh

lang/src/accounts/interface_account.rs

Lines changed: 27 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
//! Account container that checks ownership on deserialization.
22
33
use crate::accounts::account::Account;
4-
use crate::error::{Error, ErrorCode};
4+
use crate::error::ErrorCode;
55
use crate::solana_program::account_info::AccountInfo;
66
use crate::solana_program::instruction::AccountMeta;
77
use crate::solana_program::pubkey::Pubkey;
88
use crate::solana_program::system_program;
99
use crate::{
1010
AccountDeserialize, AccountSerialize, Accounts, AccountsClose, AccountsExit, CheckOwner, Key,
11-
Owners, Result, ToAccountInfo, ToAccountInfos, ToAccountMetas,
11+
Owners, Result, ToAccountInfos, ToAccountMetas,
1212
};
1313
use std::collections::BTreeSet;
1414
use std::fmt;
@@ -182,32 +182,6 @@ impl<'a, T: AccountSerialize + AccountDeserialize + Clone> InterfaceAccount<'a,
182182
}
183183
}
184184

185-
/// Reloads the account from storage. This is useful, for example, when
186-
/// observing side effects after CPI.
187-
///
188-
/// No Anchor discriminator is checked during reload. Instead, this method enforces
189-
/// owner stability by verifying that `info.owner == self.owner` (i.e., the pubkey
190-
/// validated at construction) to avoid TOCTOU issues, and then re-deserializes `T`
191-
/// from the updated bytes.
192-
///
193-
/// If you need discriminator validation on reload, use `Account<T>` with an Anchor
194-
/// #[account] type.
195-
pub fn reload(&mut self) -> Result<()> {
196-
let info = self.account.to_account_info();
197-
198-
// Enforce owner stability: must match the one validated at construction.
199-
if info.owner != &self.owner {
200-
return Err(Error::from(ErrorCode::AccountOwnedByWrongProgram)
201-
.with_pubkeys((*info.owner, self.owner)));
202-
}
203-
204-
// Re-deserialize fresh data into the inner account.
205-
let mut data: &[u8] = &info.try_borrow_data()?;
206-
let new_val = T::try_deserialize_unchecked(&mut data)?;
207-
self.account.set_inner(new_val);
208-
Ok(())
209-
}
210-
211185
pub fn into_inner(self) -> T {
212186
self.account.into_inner()
213187
}
@@ -234,28 +208,19 @@ impl<'a, T: AccountSerialize + AccountDeserialize + Clone> InterfaceAccount<'a,
234208
}
235209

236210
impl<'a, T: AccountSerialize + AccountDeserialize + CheckOwner + Clone> InterfaceAccount<'a, T> {
237-
/// Deserializes the given `info` into a `InterfaceAccount`.
238-
///
239-
/// This **does not** check an Anchor discriminator. It first validates
240-
/// program ownership via `T::check_owner`, then deserializes using
241-
/// `AccountDeserialize::try_deserialize_unchecked`.
211+
/// Deserializes the given `info` into an `InterfaceAccount`.
242212
#[inline(never)]
243213
pub fn try_from(info: &'a AccountInfo<'a>) -> Result<Self> {
244-
// `InterfaceAccount` targets foreign program accounts (e.g., SPL Token
245-
// accounts) that do not have Anchor discriminators. Because of that, we
246-
// intentionally skip the Anchor discriminator check here and instead:
247-
//
248-
// 1) Validate program ownership via `T::check_owner(info.owner)?`
249-
// 2) Deserialize without a discriminator by delegating to
250-
// `T::try_deserialize_unchecked`
251-
Self::try_from_unchecked(info)
214+
if info.owner == &system_program::ID && info.lamports() == 0 {
215+
return Err(ErrorCode::AccountNotInitialized.into());
216+
}
217+
T::check_owner(info.owner)?;
218+
let mut data: &[u8] = &info.try_borrow_data()?;
219+
Ok(Self::new(info, T::try_deserialize(&mut data)?))
252220
}
253221

254-
/// Deserializes the given `info` into a `InterfaceAccount` **without** checking
255-
/// the account discriminator. This is intended for foreign program accounts.
256-
/// Prefer `Self::try_from` when you also want the ownership check, but note
257-
/// that both skip Anchor discriminator checks, and `try_from` additionally
258-
/// enforces ownership.
222+
/// Deserializes the given `info` into an `InterfaceAccount` without checking the account
223+
/// discriminator. Be careful when using this and avoid it if possible.
259224
#[inline(never)]
260225
pub fn try_from_unchecked(info: &'a AccountInfo<'a>) -> Result<Self> {
261226
if info.owner == &system_program::ID && info.lamports() == 0 {
@@ -265,6 +230,22 @@ impl<'a, T: AccountSerialize + AccountDeserialize + CheckOwner + Clone> Interfac
265230
let mut data: &[u8] = &info.try_borrow_data()?;
266231
Ok(Self::new(info, T::try_deserialize_unchecked(&mut data)?))
267232
}
233+
234+
/// Reloads the account from storage. This is useful, for example, when observing side effects
235+
/// after CPI.
236+
///
237+
/// This method also validates that the account is owned by one of the expected programs.
238+
pub fn reload(&mut self) -> Result<()> {
239+
let info: &AccountInfo = self.account.as_ref();
240+
T::check_owner(info.owner)?;
241+
242+
// Re-deserialize fresh data into the inner account.
243+
self.account.set_inner({
244+
let mut data: &[u8] = &info.try_borrow_data()?;
245+
T::try_deserialize(&mut data)?
246+
});
247+
Ok(())
248+
}
268249
}
269250

270251
impl<'info, B, T: AccountSerialize + AccountDeserialize + CheckOwner + Clone> Accounts<'info, B>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[programs.localnet]
2+
interface_account = "interfaceAccount111111111111111111111111111"
3+
new = "New1111111111111111111111111111111111111111"
4+
old = "oLd1111111111111111111111111111111111111111"
5+
6+
[provider]
7+
cluster = "localnet"
8+
wallet = "~/.config/solana/id.json"
9+
10+
[scripts]
11+
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"

tests/interface-account/Cargo.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[workspace]
2+
members = [
3+
"programs/*"
4+
]
5+
resolver = "2"
6+
7+
[profile.release]
8+
overflow-checks = true
9+
lto = "fat"
10+
codegen-units = 1
11+
[profile.release.build-override]
12+
opt-level = 3
13+
incremental = false
14+
codegen-units = 1
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "interface-account",
3+
"version": "0.32.1",
4+
"license": "(MIT OR Apache-2.0)",
5+
"homepage": "https://github.com/coral-xyz/anchor#readme",
6+
"bugs": {
7+
"url": "https://github.com/coral-xyz/anchor/issues"
8+
},
9+
"repository": {
10+
"type": "git",
11+
"url": "https://github.com/coral-xyz/anchor.git"
12+
},
13+
"engines": {
14+
"node": ">=17"
15+
}
16+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[package]
2+
name = "interface-account"
3+
version = "0.1.0"
4+
description = "Created with Anchor"
5+
edition = "2021"
6+
7+
[lib]
8+
crate-type = ["cdylib", "lib"]
9+
name = "interface_account"
10+
11+
[features]
12+
default = []
13+
cpi = ["no-entrypoint"]
14+
no-entrypoint = []
15+
no-idl = []
16+
idl-build = ["anchor-lang/idl-build"]
17+
18+
[dependencies]
19+
anchor-lang = { path = "../../../../lang" }
20+
new = { path = "../new", features = ["cpi"] }
21+
old = { path = "../old", features = ["cpi"] }
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#![allow(warnings)]
2+
3+
use anchor_lang::prelude::*;
4+
5+
declare_id!("interfaceAccount111111111111111111111111111");
6+
7+
#[program]
8+
pub mod interface_account {
9+
use super::*;
10+
11+
pub fn test(ctx: Context<Test>) -> Result<()> {
12+
Ok(())
13+
}
14+
}
15+
16+
#[derive(Accounts)]
17+
pub struct Test<'info> {
18+
pub expected_account: InterfaceAccount<'info, interface::ExpectedAccount>,
19+
}
20+
21+
mod interface {
22+
#[derive(Clone)]
23+
pub struct ExpectedAccount(new::ExpectedAccount);
24+
25+
impl anchor_lang::AccountDeserialize for ExpectedAccount {
26+
fn try_deserialize(buf: &mut &[u8]) -> anchor_lang::Result<Self> {
27+
new::ExpectedAccount::try_deserialize(buf).map(Self)
28+
}
29+
30+
fn try_deserialize_unchecked(buf: &mut &[u8]) -> anchor_lang::Result<Self> {
31+
new::ExpectedAccount::try_deserialize_unchecked(buf).map(Self)
32+
}
33+
}
34+
35+
impl anchor_lang::AccountSerialize for ExpectedAccount {}
36+
37+
impl anchor_lang::Owners for ExpectedAccount {
38+
fn owners() -> &'static [anchor_lang::prelude::Pubkey] {
39+
&[old::ID_CONST, new::ID_CONST]
40+
}
41+
}
42+
43+
#[cfg(feature = "idl-build")]
44+
mod idl_impls {
45+
use super::ExpectedAccount;
46+
47+
impl anchor_lang::IdlBuild for ExpectedAccount {}
48+
impl anchor_lang::Discriminator for ExpectedAccount {
49+
const DISCRIMINATOR: &'static [u8] = &[];
50+
}
51+
}
52+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "new"
3+
version = "0.1.0"
4+
description = "Created with Anchor"
5+
edition = "2021"
6+
7+
[lib]
8+
crate-type = ["cdylib", "lib"]
9+
10+
[features]
11+
default = []
12+
cpi = ["no-entrypoint"]
13+
no-entrypoint = []
14+
no-idl = []
15+
idl-build = ["anchor-lang/idl-build"]
16+
17+
[dependencies]
18+
anchor-lang = { path = "../../../../lang" }
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#![allow(warnings)]
2+
3+
use anchor_lang::prelude::*;
4+
5+
declare_id!("New1111111111111111111111111111111111111111");
6+
7+
#[program]
8+
pub mod new {
9+
use super::*;
10+
11+
pub fn init(ctx: Context<Init>) -> Result<()> {
12+
Ok(())
13+
}
14+
15+
pub fn init_another(ctx: Context<InitAnother>) -> Result<()> {
16+
Ok(())
17+
}
18+
}
19+
20+
#[derive(Accounts)]
21+
pub struct Init<'info> {
22+
#[account(mut)]
23+
pub authority: Signer<'info>,
24+
#[account(init, payer = authority, space = 40)]
25+
pub expected_account: Account<'info, ExpectedAccount>,
26+
pub system_program: Program<'info, System>,
27+
}
28+
29+
#[derive(Accounts)]
30+
pub struct InitAnother<'info> {
31+
#[account(mut)]
32+
pub authority: Signer<'info>,
33+
#[account(init, payer = authority, space = 72)]
34+
pub another_account: Account<'info, AnotherAccount>,
35+
pub system_program: Program<'info, System>,
36+
}
37+
38+
#[account]
39+
pub struct ExpectedAccount {
40+
pub data: Pubkey,
41+
}
42+
43+
#[account]
44+
pub struct AnotherAccount {
45+
pub a: Pubkey,
46+
pub b: Pubkey,
47+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "old"
3+
version = "0.1.0"
4+
description = "Created with Anchor"
5+
edition = "2021"
6+
7+
[lib]
8+
crate-type = ["cdylib", "lib"]
9+
10+
[features]
11+
default = []
12+
cpi = ["no-entrypoint"]
13+
no-entrypoint = []
14+
no-idl = []
15+
idl-build = ["anchor-lang/idl-build"]
16+
17+
[dependencies]
18+
anchor-lang = { path = "../../../../lang" }

0 commit comments

Comments
 (0)