Back to skill

Security audit

Notion Api

Security checks for vulnerabilities and agentic risk

Overview

The skill is a real Notion CLI, but its install and safety claims create review-worthy risk around mutable downloaded code, token-bearing custom endpoints, and immediate Notion writes despite advertised dry-run protection.

Review before installing. Prefer the included audited file over the documented curl-from-main command, use a narrowly scoped Notion integration token, avoid setting NOTION_BASE_URL unless you fully control the endpoint, and do not rely on dry-run behavior because the artifact does not implement it.

Vulnerability Patterns
  • Remote Payload Retrieval and ExecutionFetches external code whose behavior can change after review
  • Insecure Skill Coding PracticesFinds exploitable flaws such as hardcoded secrets or command injection
  • Skill Instruction HijackingAlters the agent's session goals or safety constraints when the skill loads
  • Agent Memory PoisoningWrites attacker-controlled rules into memory that affect later sessions
  • Embedded Malicious CodeShips malicious scripts inside the skill and executes them locally
Findings (3)

T09 · Insecure Skill Coding Practices

Error
Location
notion_api.py:303
Finding

Notion API token can be transmitted to an arbitrary network destination

Content
View full analysis
str: """Return the Notion API base URL.""" return os.environ.get("NOTION_BASE_URL", DEFAULT_BASE_URL).rstrip("/") def build_headers() -> dict: """Build standard HTTP headers for Notion API calls.""" return { "Authorization": f"Bearer {get_api_key()}", "Notion-Version": get_version(), "Content-Type": "application/json", } def _request( method: str, path: str, body: dict | None = None, params: dict | None = None, ) -> dict: """ Make an HTTP request to the Notion API and return the parsed JSON response. Raises SystemExit on non-2xx responses with the API error detail. """ base = get_base_url() url = f"{base}{path}" if params: qs = "&".join(f"{k}={urllib.parse.quote(str(v))}" for k, v in params.items()) url = f"{url}?{qs}" data = None if body is not None: data = json.dumps(body).encode("utf-8") req = urllib.request.Request(url, data=data, method=method) for k, v in build_headers().items(): req.add_header(k, v) try: with urllib.request.urlopen(req) as resp: raw = resp.read().decode("utf-8") return json.loads(raw) ``` ### Technical Analysis The API base URL is read directly from the `NOTION_BASE_URL` environment variable without validating its scheme or hostname. The request builder then unconditionally adds the Notion integration token through the `Authorization: Bearer` header. Consequently, the token is not restricted to `https://api.notion.com`. If an attacker, compromised launcher, CI configuration, shell profile, or agent environment can influence `NOTION_BASE_URL`, any invocation that reaches `_request()` sends the token to the selected endpoint. The implem ...[truncated 1590 chars]
Remediation
View remediation

T03 · Remote Payload Retrieval and Execution

Error
Location
SKILL.md:30
Finding

Installation instructions retrieve mutable executable code without integrity verification

Content
View full analysis
Remediation
View remediation

T09 · Insecure Skill Coding Practices

Warning
Location
notion_api.py:455
Finding

Documented dry-run protection is absent and write operations execute immediately

Content
View full analysis
None: """Create a new page in a database.""" if not args.title: die("--title is required") if not args.database_id: die("database-id argument is required") properties: dict = { "Title": {"title": [{"text": {"content": args.title}}]}, } if args.props: try: extra = json.loads(args.props) properties.update(extra) except json.JSONDecodeError as e: die(f"Invalid --props JSON: {e}") body: dict = { "parent": {"database_id": args.database_id}, "properties": properties, } if args.icon: body["icon"] = _parse_icon(args.icon) if args.cover: body["cover"] = _parse_cover(args.cover) data = post("/pages", body) print("Page created:") _print_page_details(data) ``` ```python def cmd_update_page(args: argparse.Namespace) -> None: """Update page properties.""" if not args.props: die("--props JSON is required") try: properties = json.loads(args.props) except json.JSONDecodeError as e: die(f"Invalid --props JSON: {e}") body: dict = {"properties": properties} data = patch(f"/pages/{args.page_id}", body) print("Page updated:") _print_page_details(data) ``` ```python def cmd_append_blocks(args: ...[truncated 3153 chars]
Remediation
View remediation
Vulnerability Patterns
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Behavioral ASTexec() Call, eval() Call, Dynamic Import
  • MCP Tool PoisoningHidden Instructions, Unicode Deception, Parameter Description Injection
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
Findings (10)

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
97% confidence
Finding

This helper executes arbitrary Python files from the target product folder via python <file> self-test, which means verification of an untrusted skill will run attacker-controlled code on the CI runner or analyst machine. Because the harness is specifically meant to inspect third-party product folders, this turns validation into code execution rather than analysis, enabling compromise, data exfiltration, or persistence.

Content

Scanner excerpt · ci/verify_product.py (reported line 37)May include surrounding context.

python
def has_self_test(fp):
    r = subprocess.run([sys.executable, fp, "self-test"], capture_output=True, text=True, timeout=30)
    return r.returncode == 0 and "PASS" in r.stdout

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · ci/verify_product.py (reported line 67)May include surrounding context.

python
all_ok &= axis(False, "frontmatter (no SKILL.md)")
        all_ok &= axis(False, "docs (no SKILL.md)")
    # 3 compiles
    comp_ok = all(subprocess.run([sys.executable, "-m", "py_compile", fp],
                                capture_output=True).returncode == 0 for fp in files)
    all_ok &= axis(comp_ok, "compiles (py_compile all .py)")
    # 4 self-test (any of 3 valid forms)

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
98% confidence
Finding

This code executes any test_*.py or *_test.py file found in the inspected folder, so a malicious skill can place arbitrary payloads in a test file and have them run automatically during CI verification. In the context of a security review harness for untrusted skills, this is especially dangerous because it grants attacker code execution on the verifier environment.

Content

Scanner excerpt · ci/verify_product.py (reported line 79)May include surrounding context.

python
tests = [os.path.join(folder, f) for f in os.listdir(folder)
                 if (f.startswith("test_") or f.endswith("_test.py")) and f.endswith(".py")]
        if tests:
            st_ok = all(subprocess.run([sys.executable, t], capture_output=True, text=True,
                                      timeout=30).returncode == 0 for t in tests)
            st_note = f"{len(tests)} test_*.py"
        elif re.search(r"(?im)^test\s*:", skill_txt):

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · ci/verify_product.py (reported line 92)May include surrounding context.

python
# 7 deploy-ready
    ci = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ci_check.py")
    if os.path.isfile(ci) and os.path.isfile(skill):
        r = subprocess.run([sys.executable, ci], cwd=folder, capture_output=True, text=True)
        all_ok &= axis(r.returncode == 0, "deploy-ready (ci/ci_check.py PASS)")
    else:
        all_ok &= axis(True, "deploy-ready (skipped)")

External Transmission

Medium
Category
Data Exfiltration
Confidence
60% confidence
Finding

Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

Content

Scanner excerpt · notion_api.py (reported line 32)May include surrounding context.

python
Environment:
    NOTION_API_KEY   — required; your Notion integration token (starts with secret_ or ntn_)
    NOTION_VERSION   — optional; default "2025-09-03"
    NOTION_BASE_URL  — optional; default "https://api.notion.com/v1"
"""

import argparse

External Transmission

Medium
Category
Data Exfiltration
Confidence
77% confidence
Finding

The code allows the API destination to be overridden via NOTION_BASE_URL, while always attaching the Authorization bearer token in build_headers(). If an attacker can influence the environment, requests and the Notion API key could be redirected to an arbitrary host, enabling credential exfiltration and unintended data disclosure.

Content

Scanner excerpt · notion_api.py (reported line 49)May include surrounding context.

python
# ---------------------------------------------------------------------------

DEFAULT_VERSION = "2025-09-03"
DEFAULT_BASE_URL = "https://api.notion.com/v1"

# Ordered dict of Notion property types with description and example value shape.
PROPERTY_TYPES = {

Missing User Warnings

Medium
Category
Not specified by scanner
Confidence
88% confidence
Finding

This code performs a state-changing, potentially destructive action by archiving a Notion page, but the implementation provides no confirmation prompt or stronger user-facing warning at execution time. While the help text says 'Archive (soft-delete / move to trash)', the command itself immediately executes the action once invoked, which can lead to unintended data changes.

Content

No source excerpt is available for this finding.

Missing User Warnings

Medium
Category
Not specified by scanner
Confidence
77% confidence
Finding

The command enumerates workspace users and prints email addresses when available, which exposes personal data to whoever runs the tool. Although the command purpose implies user retrieval, the file does not provide a clear warning that personally identifiable information such as email addresses will be displayed.

Content

No source excerpt is available for this finding.

Missing User Warnings

Low
Category
Not specified by scanner
Confidence
75% confidence
Finding

This command fetches and displays an individual user's details, including email when present, but there is no explicit disclosure that personally identifiable information may be shown. Users invoking the skill may not expect email exposure unless it is clearly called out.

Content

No source excerpt is available for this finding.

Intent-Code Divergence

Low
Category
Not specified by scanner
Confidence
96% confidence
Finding

The global --json flag is documented as 'Output raw JSON instead of formatted text', which implies command handlers should switch behavior based on args.json. However, none of the command implementations check args.json; they all emit human-formatted text, so the documentation of this option contradicts actual behavior.

Content

No source excerpt is available for this finding.

Static analysis

No suspicious patterns detected.