Skip to content

Commit 93991b8

Browse files
tildemitachandraws
andauthored
fix(iam-mcp-server): treat NotAction/NotResource as broad and reject named IAM privesc actions on broad resources (#4701)
* fix(iam-mcp-server): harden inline-policy guard against NotAction/NotResource and named privesc actions _check_wildcard_policy only inspected Action/Resource and only recognized '*' and 'service:*' as broad actions, so two classes of equivalent grants bypassed it on put_user_policy/put_role_policy: - NotAction/NotResource: inverse matches such as NotAction 'iam:*' with Resource '*', or Action '*' with a NotResource, grant an unbounded set. They are now treated as a broad Action/Resource respectively, rejected only when paired with the other broad half. - Named IAM privilege-escalation actions (iam:CreateAccessKey, iam:AttachUserPolicy, iam:PutRolePolicy, iam:PassRole, ...) on a broad Resource. Matching is case-insensitive, expands wildcards such as 'iam:Put*', and accounts for NotAction. Resource-type-wide IAM ARNs such as 'role/*' also count as broad for these actions. Escalation actions scoped to specific principal ARNs, read-only IAM wildcards, and Deny statements remain allowed. * test(iam-mcp-server): cover list form of NotAction in privesc check The NotAction list branch in _check_wildcard_policy was never reached: every list-NotAction test rejected earlier at the broad action/resource check. Add cases that reach the escalation check with a list NotAction (scoped to an IAM resource-type ARN, which is not a broad Resource), and fold the repeated str-to-list normalization into a shared _as_list helper. --------- Co-authored-by: Amit Chandra <amittens@amazon.com>
1 parent 2cf9cfa commit 93991b8

2 files changed

Lines changed: 280 additions & 10 deletions

File tree

‎src/iam-mcp-server/awslabs/iam_mcp_server/server.py‎

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""AWS IAM MCP Server implementation."""
1616

1717
import argparse
18+
import fnmatch
1819
import json
1920
import re
2021
from awslabs.iam_mcp_server.aws_client import get_iam_client
@@ -78,6 +79,54 @@ def _check_denied_policy_arn(policy_arn: str) -> None:
7879
# Matches a service-scoped action wildcard such as 'iam:*' or 's3:*'.
7980
_SERVICE_WILDCARD_RE = re.compile(r'^[a-zA-Z0-9\-]+:\*$')
8081

82+
# Matches an IAM resource-type-wide wildcard such as 'arn:aws:iam::123456789012:role/*'
83+
# or 'arn:aws-us-gov:iam::*:user/*', which covers every principal of that type.
84+
_IAM_RESOURCE_TYPE_WILDCARD_RE = re.compile(r'^arn:[^:]+:iam::[^:]*:[a-z-]+/\*$')
85+
86+
# Named IAM actions that let the grantee escalate their own privileges without any
87+
# other permission (Rhino Security Labs / Cloudsplaining single-action methods), plus
88+
# iam:PassRole, which escalates when paired with any compute service, and the
89+
# permissions-boundary actions, which can lift a principal's own permission cap.
90+
# Stored lowercased because IAM action names are case-insensitive.
91+
PRIVILEGE_ESCALATION_ACTIONS = frozenset(
92+
{
93+
'iam:addusertogroup',
94+
'iam:attachgrouppolicy',
95+
'iam:attachrolepolicy',
96+
'iam:attachuserpolicy',
97+
'iam:createaccesskey',
98+
'iam:createloginprofile',
99+
'iam:createpolicyversion',
100+
'iam:deleterolepermissionsboundary',
101+
'iam:deleteuserpermissionsboundary',
102+
'iam:passrole',
103+
'iam:putgrouppolicy',
104+
'iam:putrolepermissionsboundary',
105+
'iam:putrolepolicy',
106+
'iam:putuserpermissionsboundary',
107+
'iam:putuserpolicy',
108+
'iam:setdefaultpolicyversion',
109+
'iam:updateassumerolepolicy',
110+
'iam:updateloginprofile',
111+
}
112+
)
113+
114+
115+
def _as_list(value: Any) -> Any:
116+
"""Normalize a policy element that may be a single string or a list of strings."""
117+
return [value] if isinstance(value, str) else value
118+
119+
120+
def _matching_privilege_escalation_actions(patterns: List[str]) -> set:
121+
"""Return the privilege-escalation actions matched by the given action patterns.
122+
123+
Patterns may contain IAM wildcards ('*', '?'), e.g. 'iam:Put*' or 'iam:*Policy'.
124+
"""
125+
lowered = [p.strip().lower() for p in patterns]
126+
return {
127+
a for a in PRIVILEGE_ESCALATION_ACTIONS if any(fnmatch.fnmatchcase(a, p) for p in lowered)
128+
}
129+
81130

82131
def _check_wildcard_policy(policy_document: str) -> None:
83132
"""Reject policy documents that grant overly broad access.
@@ -87,6 +136,19 @@ def _check_wildcard_policy(policy_document: str) -> None:
87136
ARN ending in ':*'). Checking only the literal '*'/'*' pair let equivalent grants
88137
such as Action 'iam:*' with Resource '*' through, which is still a full
89138
privilege-escalation primitive.
139+
140+
NotAction and NotResource are inverse matches ("everything except ..."), so they
141+
count as a broad Action and a broad Resource respectively, whatever they list. A
142+
finite exclusion list still leaves an unbounded grant, including actions and
143+
resources that don't exist yet. They are rejected only when paired with the other
144+
broad half: NotAction scoped to a specific resource ARN is still allowed, as is a
145+
specific action with NotResource (no broader than that action on Resource '*').
146+
147+
Named IAM privilege-escalation actions (see PRIVILEGE_ESCALATION_ACTIONS), including
148+
those reached through partial wildcards such as 'iam:Put*' or through NotAction, are
149+
functionally equivalent to 'iam:*' for identity management. They are rejected on a
150+
broad Resource or on every principal of a type (e.g. 'role/*'), and remain allowed
151+
when scoped to specific principal ARNs.
90152
"""
91153
doc = json.loads(policy_document)
92154
statements = doc.get('Statement', [])
@@ -95,25 +157,41 @@ def _check_wildcard_policy(policy_document: str) -> None:
95157
for stmt in statements:
96158
if stmt.get('Effect') != 'Allow':
97159
continue
98-
actions = stmt.get('Action', [])
99-
resources = stmt.get('Resource', [])
100-
if isinstance(actions, str):
101-
actions = [actions]
102-
if isinstance(resources, str):
103-
resources = [resources]
104-
105-
has_broad_action = any(a == '*' or _SERVICE_WILDCARD_RE.match(a) for a in actions)
106-
has_broad_resource = any(
160+
actions = _as_list(stmt.get('Action', []))
161+
resources = _as_list(stmt.get('Resource', []))
162+
163+
has_broad_action = 'NotAction' in stmt or any(
164+
a == '*' or _SERVICE_WILDCARD_RE.match(a) for a in actions
165+
)
166+
has_broad_resource = 'NotResource' in stmt or any(
107167
r == '*' or r == 'arn:*' or (r.startswith('arn:') and r.endswith(':*'))
108168
for r in resources
109169
)
110170

111171
if has_broad_action and has_broad_resource:
112172
raise IamValidationError(
113-
'Policy contains overly broad Action (wildcard or service:*) with broad Resource. '
173+
'Policy contains overly broad Action (wildcard, service:*, or NotAction) with '
174+
'broad Resource (wildcard or NotResource). '
114175
'Please scope the policy to specific actions and resources.'
115176
)
116177

178+
if 'NotAction' in stmt:
179+
escalation_actions = PRIVILEGE_ESCALATION_ACTIONS - (
180+
_matching_privilege_escalation_actions(_as_list(stmt['NotAction']))
181+
)
182+
else:
183+
escalation_actions = _matching_privilege_escalation_actions(actions)
184+
has_principal_wide_resource = has_broad_resource or any(
185+
_IAM_RESOURCE_TYPE_WILDCARD_RE.match(r) for r in resources
186+
)
187+
188+
if escalation_actions and has_principal_wide_resource:
189+
raise IamValidationError(
190+
f'Policy grants IAM privilege-escalation actions {sorted(escalation_actions)} '
191+
'on a broad Resource. Scope these actions to specific resource ARNs, or use '
192+
'the AWS Console for high-privilege policy changes.'
193+
)
194+
117195

118196
def _check_trust_policy_principal(policy_document: str) -> None:
119197
"""Reject trust policies that allow any AWS principal."""

‎src/iam-mcp-server/tests/test_server.py‎

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1736,6 +1736,198 @@ async def test_put_user_policy_allows_service_wildcard_with_scoped_resource():
17361736
assert 'Successfully' in result.message
17371737

17381738

1739+
@pytest.mark.asyncio
1740+
@pytest.mark.parametrize('tool_name', ['put_user_policy', 'put_role_policy'])
1741+
@pytest.mark.parametrize(
1742+
'stmt',
1743+
[
1744+
{'Effect': 'Allow', 'NotAction': 'iam:*', 'Resource': '*'},
1745+
{'Effect': 'Allow', 'NotAction': ['iam:*', 'sts:*', 'organizations:*'], 'Resource': '*'},
1746+
{'Effect': 'Allow', 'NotAction': 's3:DeleteBucket', 'Resource': 'arn:*'},
1747+
{'Effect': 'Allow', 'NotAction': 'iam:*', 'Resource': ['arn:aws:s3:::a', '*']},
1748+
{'Effect': 'Allow', 'Action': '*', 'NotResource': 'arn:aws:s3:::protected-bucket'},
1749+
{'Effect': 'Allow', 'Action': 'iam:*', 'NotResource': ['arn:aws:iam::123:role/a']},
1750+
{'Effect': 'Allow', 'NotAction': 'iam:*', 'NotResource': 'arn:aws:s3:::a'},
1751+
],
1752+
)
1753+
async def test_inline_policy_rejects_not_action_not_resource(tool_name, stmt):
1754+
"""NotAction/NotResource are inverse matches and must be treated as broad.
1755+
1756+
Inspecting only Action/Resource let statements such as NotAction 'iam:*' with
1757+
Resource '*' (every non-IAM action, account-wide) or Action '*' with a NotResource
1758+
(every action on everything but one resource) bypass the guard.
1759+
"""
1760+
from awslabs.iam_mcp_server import server
1761+
1762+
Context.initialize(readonly=False, require_confirmation=False)
1763+
target = 'role_name' if tool_name == 'put_role_policy' else 'user_name'
1764+
1765+
with patch('awslabs.iam_mcp_server.server.get_iam_client') as mock_get_client:
1766+
mock_client = Mock()
1767+
mock_get_client.return_value = mock_client
1768+
1769+
with pytest.raises(IamValidationError) as exc_info:
1770+
await getattr(server, tool_name)(
1771+
**{target: 'test-principal'},
1772+
policy_name='bad-policy',
1773+
policy_document={'Version': '2012-10-17', 'Statement': [stmt]},
1774+
confirmed=True,
1775+
)
1776+
assert 'overly broad Action' in str(exc_info.value)
1777+
getattr(mock_client, tool_name).assert_not_called()
1778+
1779+
1780+
@pytest.mark.asyncio
1781+
@pytest.mark.parametrize(
1782+
'stmt',
1783+
[
1784+
{'Effect': 'Allow', 'NotAction': 's3:DeleteObject', 'Resource': 'arn:aws:s3:::b/*'},
1785+
{'Effect': 'Allow', 'Action': 's3:GetObject', 'NotResource': 'arn:aws:s3:::secret/*'},
1786+
{'Effect': 'Deny', 'NotAction': 'iam:*', 'Resource': '*'},
1787+
{'Effect': 'Deny', 'Action': '*', 'NotResource': 'arn:aws:s3:::a'},
1788+
],
1789+
)
1790+
async def test_inline_policy_allows_scoped_not_action_not_resource(stmt):
1791+
"""NotAction/NotResource are allowed when the other half is scoped, or in Deny statements."""
1792+
from awslabs.iam_mcp_server.server import put_user_policy
1793+
1794+
Context.initialize(readonly=False, require_confirmation=False)
1795+
1796+
with patch('awslabs.iam_mcp_server.server.get_iam_client') as mock_get_client:
1797+
mock_get_client.return_value = Mock()
1798+
1799+
result = await put_user_policy(
1800+
user_name='test-user',
1801+
policy_name='scoped-policy',
1802+
policy_document={'Version': '2012-10-17', 'Statement': [stmt]},
1803+
confirmed=True,
1804+
)
1805+
assert 'Successfully' in result.message
1806+
1807+
1808+
@pytest.mark.asyncio
1809+
@pytest.mark.parametrize('tool_name', ['put_user_policy', 'put_role_policy'])
1810+
@pytest.mark.parametrize(
1811+
'stmt',
1812+
[
1813+
# Exact reproduction from the report.
1814+
{
1815+
'Effect': 'Allow',
1816+
'Action': ['iam:CreateAccessKey', 'iam:AttachUserPolicy', 'iam:PutUserPolicy'],
1817+
'Resource': '*',
1818+
},
1819+
{'Effect': 'Allow', 'Action': 'iam:PutRolePolicy', 'Resource': '*'},
1820+
{'Effect': 'Allow', 'Action': 'iam:AddUserToGroup', 'Resource': '*'},
1821+
{'Effect': 'Allow', 'Action': 'iam:CreateLoginProfile', 'Resource': '*'},
1822+
{'Effect': 'Allow', 'Action': 'iam:PassRole', 'Resource': '*'},
1823+
# IAM action names are case-insensitive.
1824+
{'Effect': 'Allow', 'Action': 'IAM:createAccessKey', 'Resource': '*'},
1825+
# Partial wildcards that expand to escalation actions.
1826+
{'Effect': 'Allow', 'Action': 'iam:Put*', 'Resource': '*'},
1827+
{'Effect': 'Allow', 'Action': 'iam:*Policy', 'Resource': 'arn:aws:iam::*:*'},
1828+
{'Effect': 'Allow', 'Action': 'iam:Create?ccessKey', 'Resource': '*'},
1829+
# Mixed with benign actions/resources in the same statement.
1830+
{
1831+
'Effect': 'Allow',
1832+
'Action': ['s3:GetObject', 'iam:CreatePolicyVersion'],
1833+
'Resource': ['arn:aws:s3:::bucket/*', '*'],
1834+
},
1835+
# Every principal of a type is as broad as '*' for escalation.
1836+
{
1837+
'Effect': 'Allow',
1838+
'Action': 'iam:CreateAccessKey',
1839+
'Resource': 'arn:aws:iam::123456789012:user/*',
1840+
},
1841+
{'Effect': 'Allow', 'Action': 'iam:*', 'Resource': 'arn:aws-us-gov:iam::*:role/*'},
1842+
{'Effect': 'Allow', 'Action': '*', 'Resource': 'arn:aws:iam::123456789012:role/*'},
1843+
# NotAction that doesn't exclude the escalation actions, on every role.
1844+
{'Effect': 'Allow', 'NotAction': 's3:*', 'Resource': 'arn:aws:iam::123:role/*'},
1845+
{
1846+
'Effect': 'Allow',
1847+
'NotAction': ['s3:*', 'iam:CreateAccessKey'],
1848+
'Resource': 'arn:aws:iam::123:role/*',
1849+
},
1850+
# Escalation action with NotResource.
1851+
{'Effect': 'Allow', 'Action': 'iam:UpdateAssumeRolePolicy', 'NotResource': 'x'},
1852+
],
1853+
)
1854+
async def test_inline_policy_rejects_privilege_escalation_actions(tool_name, stmt):
1855+
"""Named IAM privesc actions on a broad Resource are equivalent to 'iam:*'.
1856+
1857+
Recognizing only '*' and 'service:*' let canonical escalation actions such as
1858+
iam:CreateAccessKey or iam:AttachUserPolicy with Resource '*' bypass the guard.
1859+
"""
1860+
from awslabs.iam_mcp_server import server
1861+
1862+
Context.initialize(readonly=False, require_confirmation=False)
1863+
target = 'role_name' if tool_name == 'put_role_policy' else 'user_name'
1864+
1865+
with patch('awslabs.iam_mcp_server.server.get_iam_client') as mock_get_client:
1866+
mock_client = Mock()
1867+
mock_get_client.return_value = mock_client
1868+
1869+
with pytest.raises(IamValidationError) as exc_info:
1870+
await getattr(server, tool_name)(
1871+
**{target: 'test-principal'},
1872+
policy_name='bad-policy',
1873+
policy_document={'Version': '2012-10-17', 'Statement': [stmt]},
1874+
confirmed=True,
1875+
)
1876+
assert 'privilege-escalation' in str(exc_info.value)
1877+
getattr(mock_client, tool_name).assert_not_called()
1878+
1879+
1880+
@pytest.mark.asyncio
1881+
@pytest.mark.parametrize(
1882+
'stmt',
1883+
[
1884+
# Self-service credential management scoped to the caller.
1885+
{
1886+
'Effect': 'Allow',
1887+
'Action': ['iam:CreateAccessKey', 'iam:UpdateLoginProfile'],
1888+
'Resource': 'arn:aws:iam::123456789012:user/${aws:username}',
1889+
},
1890+
{
1891+
'Effect': 'Allow',
1892+
'Action': 'iam:PassRole',
1893+
'Resource': 'arn:aws:iam::123456789012:role/my-lambda-role',
1894+
},
1895+
# Path-scoped, not every role in the account.
1896+
{
1897+
'Effect': 'Allow',
1898+
'Action': 'iam:AttachRolePolicy',
1899+
'Resource': 'arn:aws:iam::123456789012:role/app/*',
1900+
},
1901+
# Read-only IAM wildcards don't expand to any escalation action.
1902+
{'Effect': 'Allow', 'Action': ['iam:Get*', 'iam:List*'], 'Resource': '*'},
1903+
# NotAction excluding all of IAM, on role ARNs, grants nothing that escalates.
1904+
{'Effect': 'Allow', 'NotAction': 'iam:*', 'Resource': 'arn:aws:iam::123:role/*'},
1905+
{
1906+
'Effect': 'Allow',
1907+
'NotAction': ['iam:*', 'sts:*'],
1908+
'Resource': 'arn:aws:iam::123:role/*',
1909+
},
1910+
{'Effect': 'Deny', 'Action': 'iam:CreateAccessKey', 'Resource': '*'},
1911+
],
1912+
)
1913+
async def test_inline_policy_allows_scoped_privilege_escalation_actions(stmt):
1914+
"""Escalation actions on specific principals, and read-only IAM access, are allowed."""
1915+
from awslabs.iam_mcp_server.server import put_user_policy
1916+
1917+
Context.initialize(readonly=False, require_confirmation=False)
1918+
1919+
with patch('awslabs.iam_mcp_server.server.get_iam_client') as mock_get_client:
1920+
mock_get_client.return_value = Mock()
1921+
1922+
result = await put_user_policy(
1923+
user_name='test-user',
1924+
policy_name='scoped-policy',
1925+
policy_document={'Version': '2012-10-17', 'Statement': [stmt]},
1926+
confirmed=True,
1927+
)
1928+
assert 'Successfully' in result.message
1929+
1930+
17391931
@pytest.mark.asyncio
17401932
async def test_put_user_policy_rejects_wildcard():
17411933
"""Test that put_user_policy rejects Action:* with Resource:*."""

0 commit comments

Comments
 (0)