From hsns
Use when reviewing HubSpot CMS code for HubL XSS via `|safe`, secrets in shipped JS, unsafe form actions, CSP-incompatible patterns, and OWASP issues specific to HubSpot-hosted forms. Companion to /hsns:security.
How this skill is triggered — by the user, by Claude, or both
Slash command
/hsns:hubl-securityThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
HubL renders server-side; it auto-escapes most output. The vulnerabilities specific to HubSpot are: (1) `|safe` filter mis-use, (2) secrets in client-shipped code, (3) unsafe form/redirect handling, (4) CSP-incompatible inline JS/handlers, (5) data-handling issues that compound at HubSpot scale.
HubL renders server-side; it auto-escapes most output. The vulnerabilities specific to HubSpot are: (1) |safe filter mis-use, (2) secrets in client-shipped code, (3) unsafe form/redirect handling, (4) CSP-incompatible inline JS/handlers, (5) data-handling issues that compound at HubSpot scale.
|safe filter — primary XSS vectorHubL escapes by default. |safe opts out, rendering the value as raw HTML. This is the #1 source of HubSpot XSS.
When |safe is dangerous:
text field — the editor can paste a script tag and HubL will render it as runnable HTML.richtext field WITHOUT trusting HubSpot's editor sanitization — though in practice the editor does sanitize on save.url field — pasting a javascript: scheme value gets rendered into <a href=""> and clicked = code execution.When |safe is justified:
{{ content.absolute_url|safe }} — these are URLs HubSpot constructed, safe by trust.{{ value|striptags|safe }} — striptags removes all HTML, safe ensures it isn't double-escaped.{{ "<svg>...</svg>"|safe }} — author-controlled, not editor-controlled.Discipline: every |safe use should have a comment explaining why:
{# safe-after-striptags #}
{{ module.user_supplied_html | striptags | safe }}
{# safe-system-value #}
{{ content.absolute_url|safe }}
{# safe-after-editor-sanitization #}
{# HubSpot's richtext editor sanitizes on save; |safe avoids double-escaping the stored HTML. #}
{{ module.subhead | safe }}
/hsns:security triages every |safe and demands a justification comment for each.
HubL's auto-escape covers HTML body context but is less aggressive in attribute contexts. Always be explicit:
{# WEAK — relies on auto-escape #}
<a href="{{ module.cta_url }}">
{# STRONG — explicit #}
<a href="{{ module.cta_url|escape }}">
{# WEAK — javascript: scheme not blocked #}
<a href="{{ module.cta_url }}">
{# STRONG — validate the scheme too #}
{% if module.cta_url|lower|startswith("https://") or module.cta_url|startswith("/") %}
<a href="{{ module.cta_url|escape }}">…</a>
{% endif %}
JSON-in-HTML: use |escape (or build it server-side and stick the JSON in a data attribute):
{# WEAK — XSS in JSON via " injection #}
<script>const data = {{ module.json|safe }};</script>
{# STRONG #}
<script id="data" type="application/json">{{ module.json|escape }}</script>
<script>const data = JSON.parse(document.getElementById('data').textContent);</script>
module.js, inline <script>, module.css, and any HubL template all ship to the browser. Anything an end user can read.
Never put in shipped code:
OK in shipped code:
G-XXXX, Meta Pixel ID, GTM container ID, HubSpot tracking ID).{{ portal_id }} is public).Where /hsns:security looks: every *.js, *.html, *.css in the repo. Heuristic:
grep -rEn '(api[_-]?key|secret|token|password|bearer)\s*[:=]\s*["'\''][^"'\''\s]{16,}'
HubSpot forms via {% form %} are safe by default — HubSpot handles submission, spam filtering, validation, and CSRF.
Custom <form action="...">:
blocking.response_redirect_url:
/thanks) or an explicit allowlisted external domain.response_redirect_url={{ request.query_dict.next }}) is a phishing vector — blocking.Form method:
HubSpot doesn't enforce CSP by default. But customer portals often do (regulated industries, compliance teams). Build CSP-friendly by default:
Avoid:
<button onclick="..."> → blocking (also XSS-prone).<script> without a nonce — should_fix. Move JS to module.js.<script src="https://..."> from non-HubSpot CDNs without SRI hashes → should_fix.blocking.Prefer:
module.js (or a theme-level js/main.js).For lead-capture forms:
autocomplete="off" on fields where stored auto-fill is harmful (security questions, payment); allow on standard fields (new-password, current-password).{% form %}; custom endpoints must implement)./hsns:security flags missing as nitpick).For pages serving EU/UK/CA visitors:
standard_header_includes injects tracking; consent integration is a portal-level setting.| Pattern | Verdict |
|---|---|
{{ module.text }} | safe (auto-escaped) |
{{ module.richtext }} | safe (richtext is HTML; HubSpot sanitizes on save) |
{{ module.text | safe }} | blocking unless justified |
{{ module.url | escape }} in href="" | safe |
<a href="{{ module.url }}"> | should_fix → add |escape |
<button onclick="..."> | blocking (CSP + XSS) |
<script>const x = {{ module.json }};</script> | blocking — use data-attribute |
<script>const x = "{{ module.text }}";</script> | should_fix — quote injection |
<form action="https://api.example.com/lead" method="post"> (3rd-party) | needs origin/CSRF on receiving end |
response_redirect_url="{{ request.query_dict.next }}" | blocking — open redirect |
API key in module.js | blocking |
GA4 ID in module.js | safe |
eyJ... JWT in shipped code | blocking (likely a leaked credential) |
/hsns:security runsscripts/hubl-lint.sh for the |safe-on-module-var heuristic.<form>, <a>, <script> tag for context-specific checks..hs-nano/security/<TS>.json with category + OWASP ref + suggestion.npx claudepluginhub todoviernes/hs-nano-stack --plugin hsnsGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.