Skip to content

Commit b332f91

Browse files
fix(ci): cap Figma REST bodies and refuse Host overrides
Absorb the #1032 security extras: allow only X-Figma-Token, cap whoami/file bodies, cite CWE-22 and plan tokens, and keep a live unauthenticated /v1/me accuracy check. Prefer this head over #1032. Co-authored-by: Seongho Bae <[email protected]>
1 parent 8fe679d commit b332f91

6 files changed

Lines changed: 148 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Semantic Versioning where the repository publishes a release.
4040

4141
### Security
4242

43+
- Pin Figma REST calls to `http.client.HTTPSConnection("api.figma.com")`, allow only the `X-Figma-Token` header, allowlist file keys and node ids, and cap whoami/file bodies so `file://`, `Host` retargeting, and unbounded reads cannot leave the helper.
4344
- Reject `.github/` and `scripts/ci/` from review-thread-derived autofix path authority so an untrusted inline reviewer cannot authorize the write-capable repair agent to modify workflows, CODEOWNERS, actions, scheduler code, or CI helpers that govern its own control plane.
4445
- Require the model-write snapshot and exact-path allowlist to remain outside the pull-request worktree, checking both absolute and resolved locations so repository-local controls and outside-looking symlinks resolving into the repository fail closed before they can authorize or verify model changes.
4546
- Snapshot the complete pre-model worktree for ordinary and conflict repair and reject every model-caused created, deleted, modified, mode-changed, retargeted, ignored, dangling, directory-backed, external-link, metadata-race, or out-of-scope path before staging or push.

‎docs/doctoring/figma-cloud-agent-mcp-auth.md‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,19 @@ Use two disjoint auth paths:
4040
| Surface | Auth | Capability |
4141
|---|---|---|
4242
| Cursor Desktop / CLI | Figma MCP OAuth (`Settings → Tools & MCP → Figma → Connect`) | Full MCP toolset (`get_design_context`, `use_figma`, write-to-canvas, …) |
43-
| Cursor Cloud Agent | Figma personal access token in `FIGMA_ACCESS_TOKEN` | REST only: `python3 scripts/ci/figma_rest_auth.py` then `python3 scripts/ci/figma_rest_file.py <file-key-or-url>` (`X-Figma-Token` on pinned `https://api.figma.com/v1/me` and `/v1/files/{key}`) |
43+
| Cursor Cloud Agent | Figma personal or plan access token in `FIGMA_ACCESS_TOKEN` | REST only: `python3 scripts/ci/figma_rest_auth.py` then `python3 scripts/ci/figma_rest_file.py <file-key-or-url>` (`X-Figma-Token` only, pinned `https://api.figma.com/v1/me` and `/v1/files/{key}`) |
4444

45-
A personal access token does **not** unlock Figma MCP on Cloud Agents. It only
46-
authorizes the REST API. Do not commit the token. Do not put it in
45+
A personal or plan access token does **not** unlock Figma MCP on Cloud Agents.
46+
It only authorizes the REST API. Do not commit the token. Do not put it in
4747
`environment.json`, workflow YAML, or chat output.
4848

49+
Prefer a **plan access token** for organization Cloud Agent fleets
50+
(admin-managed, expiry up to one year; Figma, 2026a). Use a personal access
51+
token only when the operator is acting on their own account (maximum 90 days).
52+
Both kinds are stored in the same secret name. Whoami and file bodies are
53+
capped (64 KiB / 8 MiB). The opener refuses every header except
54+
`X-Figma-Token` so a `Host` override cannot retarget TLS (CWE-22; MITRE, 2026).
55+
4956
## Operator procedure
5057

5158
1. **Desktop / CLI MCP (preferred for design-to-code).** In Cursor Desktop,
@@ -123,6 +130,13 @@ authorization framework* (Internet-Draft draft-ietf-oauth-v2-1). Internet
123130
Engineering Task Force. Retrieved August 16, 2026, from
124131
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1
125132

133+
MITRE. (2026). *CWE-22: Improper limitation of a pathname to a restricted
134+
directory ('Path Traversal')*. https://cwe.mitre.org/data/definitions/22.html
135+
126136
Neilson, K. (2026, June 10). Reply in *Figma MCP shows "Forbidden" in
127137
Automations / Cloud Agents*. Cursor Forum. Retrieved August 16, 2026, from
128138
https://forum.cursor.com/t/figma-mcp-shows-forbidden-in-automations-cloud-agents/162969
139+
140+
Sakimura, N., Bradley, J., & Agarwal, N. (2015). *Proof Key for Code Exchange
141+
by OAuth public clients* (RFC 7636). RFC Editor.
142+
https://doi.org/10.17487/RFC7636

‎scripts/ci/figma_rest_auth.py‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
TOKEN_HEADER = "X-Figma-Token"
2121
WHOAMI_URL = "https://api.figma.com/v1/me"
2222
REQUEST_TIMEOUT_SECONDS = 20
23+
MAX_WHOAMI_BODY_BYTES = 65_536
2324
EXIT_OK = 0
2425
EXIT_MISSING_TOKEN = 2
2526
EXIT_REJECTED = 3
2627
EXIT_TRANSPORT = 4
2728
Opener = Callable[[str, Mapping[str, str]], tuple[int, bytes]]
29+
BoundedReader = Callable[[int], bytes]
2830

2931

3032
class FigmaAuthError(Exception):
@@ -55,6 +57,41 @@ def read_access_token(environ: Mapping[str, str]) -> str:
5557
return token
5658

5759

60+
def sanitize_request_headers(headers: Mapping[str, str]) -> dict[str, str]:
61+
"""Allow only ``X-Figma-Token`` so a ``Host`` header cannot retarget TLS."""
62+
sanitized: dict[str, str] = {}
63+
for name, value in headers.items():
64+
if name.lower() != TOKEN_HEADER.lower():
65+
raise FigmaAuthError(
66+
f"Figma REST opener refuses header {name!s} other than "
67+
f"{TOKEN_HEADER}.",
68+
EXIT_TRANSPORT,
69+
)
70+
if not value.strip():
71+
raise FigmaAuthError(
72+
"Figma REST token header is empty.",
73+
EXIT_TRANSPORT,
74+
)
75+
sanitized[TOKEN_HEADER] = value
76+
return sanitized
77+
78+
79+
def read_bounded_body(read: BoundedReader, limit: int) -> bytes:
80+
"""Read at most ``limit`` bytes or raise ``FigmaAuthError``."""
81+
if limit < 1:
82+
raise FigmaAuthError(
83+
"Figma REST body limit must be a positive byte count.",
84+
EXIT_TRANSPORT,
85+
)
86+
payload = read(limit + 1)
87+
if len(payload) > limit:
88+
raise FigmaAuthError(
89+
f"Figma REST response exceeded {limit} bytes.",
90+
EXIT_TRANSPORT,
91+
)
92+
return payload
93+
94+
5895
def default_opener(url: str, headers: Mapping[str, str]) -> tuple[int, bytes]:
5996
"""GET the fixed Figma whoami origin and return ``(status, body)``.
6097
@@ -74,9 +111,9 @@ def default_opener(url: str, headers: Mapping[str, str]) -> tuple[int, bytes]:
74111
timeout=REQUEST_TIMEOUT_SECONDS,
75112
)
76113
try:
77-
connection.request("GET", "/v1/me", headers=dict(headers))
114+
connection.request("GET", "/v1/me", headers=sanitize_request_headers(headers))
78115
response = connection.getresponse()
79-
return int(response.status), response.read()
116+
return int(response.status), read_bounded_body(response.read, MAX_WHOAMI_BODY_BYTES)
80117
except OSError as exc:
81118
raise FigmaAuthError(
82119
f"Figma REST transport failed: {exc}",

‎scripts/ci/figma_rest_file.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
FigmaAuthError,
3131
identity_field,
3232
read_access_token,
33+
read_bounded_body,
34+
sanitize_request_headers,
3335
)
3436

3537
EXIT_INVALID_TARGET = 5
@@ -48,6 +50,7 @@
4850
)
4951
DEFAULT_TREE_DEPTH = 2
5052
MAX_TREE_DEPTH = 8
53+
MAX_FILE_BODY_BYTES = 8_388_608
5154
FileOpener = Callable[[str, Mapping[str, str]], tuple[int, bytes]]
5255

5356

@@ -169,9 +172,9 @@ def default_file_opener(path: str, headers: Mapping[str, str]) -> tuple[int, byt
169172
timeout=REQUEST_TIMEOUT_SECONDS,
170173
)
171174
try:
172-
connection.request("GET", path, headers=dict(headers))
175+
connection.request("GET", path, headers=sanitize_request_headers(headers))
173176
response = connection.getresponse()
174-
return int(response.status), response.read()
177+
return int(response.status), read_bounded_body(response.read, MAX_FILE_BODY_BYTES)
175178
except OSError as exc:
176179
raise FigmaAuthError(
177180
f"Figma REST transport failed: {exc}",

‎tests/test_figma_rest_auth.py‎

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,11 @@ def __init__(self, status: int, body: bytes) -> None:
135135
self.status = status
136136
self._body = body
137137

138-
def read(self) -> bytes:
139-
"""Return the canned body."""
140-
return self._body
138+
def read(self, amt: int | None = None) -> bytes:
139+
"""Return the canned body, honoring an optional byte limit."""
140+
if amt is None:
141+
return self._body
142+
return self._body[:amt]
141143

142144

143145
class _FakeWhoamiConnection:
@@ -249,6 +251,61 @@ def request(self, method: str, path: str, headers: dict[str, str] | None = None)
249251
assert FailingConnection.last.closed is True
250252

251253

254+
def test_sanitize_request_headers_allows_only_figma_token() -> None:
255+
"""A Host or empty token header never reaches ``HTTPSConnection.request``."""
256+
assert auth.sanitize_request_headers({}) == {}
257+
assert auth.sanitize_request_headers({auth.TOKEN_HEADER: TOKEN}) == {
258+
auth.TOKEN_HEADER: TOKEN
259+
}
260+
with pytest.raises(auth.FigmaAuthError) as host:
261+
auth.sanitize_request_headers({auth.TOKEN_HEADER: TOKEN, "Host": "evil.example"})
262+
assert host.value.exit_code == auth.EXIT_TRANSPORT
263+
assert "Host" in str(host.value)
264+
assert TOKEN not in str(host.value)
265+
with pytest.raises(auth.FigmaAuthError) as blank:
266+
auth.sanitize_request_headers({auth.TOKEN_HEADER: " "})
267+
assert blank.value.exit_code == auth.EXIT_TRANSPORT
268+
269+
270+
def test_read_bounded_body_rejects_oversize_and_nonpositive_limits() -> None:
271+
"""Response bodies cannot grow past the configured byte cap."""
272+
assert auth.read_bounded_body(lambda amt: b"ok"[:amt], 8) == b"ok"
273+
with pytest.raises(auth.FigmaAuthError) as oversize:
274+
auth.read_bounded_body(lambda amt: b"x" * amt, 4)
275+
assert oversize.value.exit_code == auth.EXIT_TRANSPORT
276+
assert "4" in str(oversize.value)
277+
with pytest.raises(auth.FigmaAuthError) as invalid:
278+
auth.read_bounded_body(lambda amt: b"", 0)
279+
assert invalid.value.exit_code == auth.EXIT_TRANSPORT
280+
281+
282+
def test_default_opener_rejects_oversize_whoami_body(monkeypatch: pytest.MonkeyPatch) -> None:
283+
"""A whoami body larger than 64 KiB is a transport failure."""
284+
285+
class HugeConnection(_FakeWhoamiConnection):
286+
"""Return more bytes than the whoami cap."""
287+
288+
def __init__(self, host: str, timeout: int = 0) -> None:
289+
"""Initialize an oversized body."""
290+
super().__init__(host, timeout)
291+
self._body = b"x" * (auth.MAX_WHOAMI_BODY_BYTES + 1)
292+
293+
monkeypatch.setattr(auth.http.client, "HTTPSConnection", HugeConnection)
294+
with pytest.raises(auth.FigmaAuthError) as oversize:
295+
auth.default_opener(auth.WHOAMI_URL, {auth.TOKEN_HEADER: TOKEN})
296+
assert oversize.value.exit_code == auth.EXIT_TRANSPORT
297+
assert str(auth.MAX_WHOAMI_BODY_BYTES) in str(oversize.value)
298+
299+
300+
def test_live_unauthenticated_whoami_is_rejected_by_figma() -> None:
301+
"""The real ``/v1/me`` endpoint rejects a missing token with HTTP 401/403."""
302+
status, body = auth.default_opener(auth.WHOAMI_URL, {})
303+
assert status in {401, 403}
304+
assert TOKEN not in body.decode("utf-8", errors="replace")
305+
lowered = body.lower()
306+
assert b"token" in lowered or b"unauthorized" in lowered or b"invalid" in lowered
307+
308+
252309
def test_helper_pins_https_origin_instead_of_dynamic_urllib() -> None:
253310
"""Semgrep ``dynamic-urllib-use-detected`` must not apply to this helper."""
254311
source = Path(auth.__file__).read_text(encoding="utf-8")

‎tests/test_figma_rest_file.py‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,11 @@ def __init__(self, status: int, body: bytes) -> None:
143143
self.status = status
144144
self._body = body
145145

146-
def read(self) -> bytes:
147-
"""Return the canned body."""
148-
return self._body
146+
def read(self, amt: int | None = None) -> bytes:
147+
"""Return the canned body, honoring an optional byte limit."""
148+
if amt is None:
149+
return self._body
150+
return self._body[:amt]
149151

150152

151153
class _FakeFileConnection:
@@ -197,6 +199,24 @@ def test_default_file_opener_reads_success_body(monkeypatch: pytest.MonkeyPatch)
197199
assert connection.closed is True
198200

199201

202+
def test_default_file_opener_rejects_oversize_body(monkeypatch: pytest.MonkeyPatch) -> None:
203+
"""A file body larger than 8 MiB is a transport failure."""
204+
205+
class HugeConnection(_FakeFileConnection):
206+
"""Return more bytes than the file cap."""
207+
208+
def __init__(self, host: str, timeout: int = 0) -> None:
209+
"""Initialize an oversized body."""
210+
super().__init__(host, timeout)
211+
self._body = b"x" * (files.MAX_FILE_BODY_BYTES + 1)
212+
213+
monkeypatch.setattr(files.http.client, "HTTPSConnection", HugeConnection)
214+
with pytest.raises(auth.FigmaAuthError) as oversize:
215+
files.default_file_opener(files.build_request_path(FILE_KEY), {auth.TOKEN_HEADER: TOKEN})
216+
assert oversize.value.exit_code == auth.EXIT_TRANSPORT
217+
assert str(files.MAX_FILE_BODY_BYTES) in str(oversize.value)
218+
219+
200220
def test_default_file_opener_wraps_os_errors(monkeypatch: pytest.MonkeyPatch) -> None:
201221
"""Network failures become ``EXIT_TRANSPORT`` without leaking the token."""
202222

@@ -470,6 +490,9 @@ def test_doctoring_and_entry_docs_pin_file_read_fallback() -> None:
470490
assert "APA 7th references" in doctoring
471491
assert "Retrieved August 16, 2026" in doctoring
472492
assert "file-endpoints" in doctoring
493+
assert "CWE-22" in doctoring
494+
assert "plan access token" in doctoring
495+
assert "X-Figma-Token" in changelog
473496
assert "scripts/ci/figma_rest_file.py" in changelog
474497
assert "Figma Cloud Agent REST" in architecture
475498
assert "FIGMA_ACCESS_TOKEN" in claude

0 commit comments

Comments
 (0)