Back to skill

Security audit

Newsletter Automation

Security checks for vulnerabilities and agentic risk

Overview

This is a coherent newsletter automation skill, but a broadcast-authentication flaw could let a misconfigured public endpoint send email to every confirmed subscriber.

Review before installing. Do not activate the broadcast workflow until NEWSLETTER_SECRET is set to a strong random value and the public fallback is removed; put the webhooks behind authentication or rate limiting, sanitize subscriber-supplied fields before email rendering, add signup abuse controls, and fix the drip state so welcome emails are sent only once. Also publish a privacy/consent notice and restrict access to the subscriber Sheet.

Vulnerability Patterns
  • 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
  • Remote Payload Retrieval and ExecutionFetches external code whose behavior can change after review
  • Embedded Malicious CodeShips malicious scripts inside the skill and executes them locally
Findings (5)

T09 · Insecure Skill Coding Practices

Error
Location
workflows/03-broadcast-sender.json:23
Finding
Broadcast Authentication Bypass Through a Public Fallback Secret<![CDATA[ ## Vulnerability Details **File Location**: `workflows/03-broadcast-sender.json:23` **Vulnerability Type**: Authentication fail-open caused by a known fallback credential **Risk Level**: High ### Vulnerable Code ```js const body = $input.first().json.body || $input.first().json; const subject = (body.subject || '').trim(); const content = (body.content || '').trim(); const secret = (body._secret || '').trim(); if (!secret || secret !== ($env.NEWSLETTER_SECRET || 'YOUR_NEWSLETTER_SECRET')) { return [{ json: { error: 'Unauthorized', valid: false } }]; } if (!subject || !content) { return [{ json: { error: 'Subject and content are required', valid: false } }]; } return [{ json: { subject, content, valid: true, sent_at: new Date().toISOString() } }]; ``` ### Technical Analysis The broadcast webhook authenticates requests by comparing a request-body value against `NEWSLETTER_SECRET`. If that environment variable is missing or empty, authentication silently falls back to the literal string `YOUR_NEWSLETTER_SECRET`. That fallback value is present in the publicly distributed workflow and is therefore not secret. A deployment that omits the environment variable will accept the documented placeholder as a valid credential. This is a fail-open configuration defect on a high-impact bulk-email operation. The secret is also supplied in the request body rather than through n8n credential handling or an authorization header. Request bodies may be retained in workflow execution histories, reverse-proxy logs, debugging systems, and monitoring products. ### Attack Path 1. An administrator imports and activates the workflow without configuring `NEWSLETTER_SECRET`. 2. The public endpoint `/webhook/newsletter/broadcast` becomes reachable. 3. An attacker sends a POST request containing: ```json { "_secret": "YOUR_NEWSLETTER_SECRET", "subject": "Attacker-controlled subject", "content": "<p>Attacker-controlled HTML</p>" ...[truncated 733 chars]
Remediation
<![CDATA[ ## Remediation Suggestions - Fail closed when `NEWSLETTER_SECRET` is unavailable: ```js const configuredSecret = $env.NEWSLETTER_SECRET; if (!configuredSecret) { throw new Error('NEWSLETTER_SECRET is not configured'); } ``` - Remove the public placeholder as an authentication fallback. - Use n8n-supported webhook authentication or place the endpoint behind an authenticated API gateway. - Supply credentials through an authorization header rather than the request body. - Compare secret values using a timing-safe comparison where the runtime supports it. - Use a randomly generated, high-entropy secret and document a rotation procedure. - Add request rate limits, replay protection, audit logging, and alerts for bulk sends. - Require explicit administrative approval or a preview/confirmation step before dispatching a broadcast. - Disable saving successful and failed execution payloads where possible so secrets and message contents are not retained unnecessarily. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
workflows/01-subscriber-signup.json:23
Finding
Confirmation Tokens Generated With a Non-Cryptographic PRNG<![CDATA[ ## Vulnerability Details **File Location**: `workflows/01-subscriber-signup.json:23` **Vulnerability Type**: Predictable security token generation **Risk Level**: Medium ### Vulnerable Code ```js const body = $input.first().json.body || $input.first().json; const email = (body.email || '').trim().toLowerCase(); const name = (body.name || '').trim(); if (!email || !email.includes('@')) { return [{ json: { error: 'Valid email is required', valid: false } }]; } // Generate confirmation token const token = Array.from({ length: 32 }, () => Math.random().toString(36).charAt(2) ).join(''); return [{ json: { email, name: name || 'Subscriber', token, status: 'pending', source: body.source || 'website', subscribed_at: new Date().toISOString(), confirmed: false, valid: true } }]; ``` ### Technical Analysis `Math.random()` is not a cryptographically secure pseudorandom number generator. Its output is not designed to resist state inference or prediction by an adversary. Calling it once per character does not convert it into a secure token source. The generated token is intended to authorize a subscriber-confirmation operation. It is stored in Google Sheets and placed in a URL sent through email. Security-sensitive tokens should have cryptographically strong entropy, a limited validity period, one-time-use enforcement, and secure storage. The supplied project does not include the confirmation endpoint claimed by its documentation. Consequently, direct exploitation cannot be completed using only the included files. The weakness becomes exploitable when this token is accepted by an external or subsequently added confirmation handler. ### Attack Path 1. An attacker triggers signups and collects tokens sent to email addresses under the attacker's control. 2. The attacker uses observed output and runtime-specific PRNG behavior to attempt prediction or reduction of the search space for another generated token. 3. The atta ...[truncated 903 chars]
Remediation
<![CDATA[ ## Remediation Suggestions - Generate at least 128 bits of entropy using a cryptographically secure random-number generator, such as `crypto.randomBytes(32)`. - Encode tokens using base64url or hexadecimal without reducing entropy. - Store only a cryptographic hash of each token rather than the reusable plaintext token. - Bind the token to the relevant subscriber record and intended action. - Add a short expiration time and enforce one-time use. - Invalidate older tokens when a signup is repeated. - Compare token hashes using a timing-safe operation. - Implement and audit the missing confirmation handler before describing the package as a complete double-opt-in system. - Avoid placing the email address in the confirmation URL when the token can uniquely identify the pending record. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
workflows/01-subscriber-signup.json:5
Finding
Public Signup Webhook Can Be Used for Unsolicited Email and Resource Abuse<![CDATA[ ## Vulnerability Details **File Location**: `workflows/01-subscriber-signup.json:5-23, 158-160` **Vulnerability Type**: Unrestricted externally triggered email operation **Risk Level**: Medium ### Vulnerable Code ```json { "parameters": { "httpMethod": "POST", "path": "newsletter/signup", "responseMode": "responseNode", "options": {} }, "name": "Signup Webhook", "type": "n8n-nodes-base.webhook" } ``` ```js const body = $input.first().json.body || $input.first().json; const email = (body.email || '').trim().toLowerCase(); const name = (body.name || '').trim(); if (!email || !email.includes('@')) { return [{ json: { error: 'Valid email is required', valid: false } }]; } ``` ```json { "parameters": { "sendTo": "={{ $json.email }}", "subject": "=Please confirm your subscription", "message": "=<h2>Welcome, {{ $json.name }}!</h2><p>Thanks for subscribing to our newsletter.</p><p>Please confirm your subscription by clicking the link below:</p><p><a href=\"{{ $env.NEWSLETTER_BASE_URL || 'https://YOUR_DOMAIN' }}/confirm?token={{ $json.token }}&email={{ $json.email }}\">Confirm Subscription</a></p><p>If you didn't subscribe, you can safely ignore this email.</p>", "options": {} } } ``` ### Technical Analysis A newsletter signup endpoint must normally be public, but the workflow provides no compensating abuse controls. There is no CAPTCHA, signed-form verification, IP or address-based rate limit, replay prevention, cooldown, request-size limit, or robust email schema validation. The only validation is whether the supplied value contains `@`. Every accepted request can write a record to Google Sheets and trigger an SMTP email. Because storage uses `appendOrUpdate` with the email address as the matching field, repeated requests can also replace pending subscriber data and confirmation tokens. The network operation itself is necessary for double opt-in, but allowing unlimited anonymous callers to invoke it excee ...[truncated 1010 chars]
Remediation
<![CDATA[ ## Remediation Suggestions - Apply rate limits at the reverse proxy or API gateway by IP address, email-address hash, and global request volume. - Add CAPTCHA, proof-of-work, or signed requests for browser-hosted signup forms. - Enforce a cooldown before issuing another confirmation email to the same address. - Use a strict email-validation library or schema and impose limits on every input field. - Reject unexpectedly large request bodies and unknown fields. - Preserve an existing confirmed subscriber record instead of resetting it through `appendOrUpdate`. - Do not rotate a pending token on every anonymous request unless an appropriate cooldown has elapsed. - Add abuse monitoring and alerts for unusual signup rates. - Configure SMTP provider limits and n8n concurrency limits. - Return the same generic response for existing and new addresses to avoid subscriber enumeration. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
workflows/01-subscriber-signup.json:23
Finding
Subscriber-Controlled Values Are Embedded Into HTML Email Without Escaping<![CDATA[ ## Vulnerability Details **File Location**: `workflows/01-subscriber-signup.json:23,160`; `workflows/02-welcome-sequence.json:100`; `workflows/03-broadcast-sender.json:114`; `workflows/04-subscriber-analytics.json:44-71` **Vulnerability Type**: HTML injection into generated email content **Risk Level**: Medium ### Vulnerable Code Public signup data is accepted without HTML sanitization: ```js const body = $input.first().json.body || $input.first().json; const email = (body.email || '').trim().toLowerCase(); const name = (body.name || '').trim(); return [{ json: { email, name: name || 'Subscriber', token, status: 'pending', source: body.source || 'website', subscribed_at: new Date().toISOString(), confirmed: false, valid: true } }]; ``` The name is inserted directly into confirmation email HTML: ```html <h2>Welcome, {{ $json.name }}!</h2> <p>Thanks for subscribing to our newsletter.</p> ``` The welcome workflow uses the stored name directly in HTML templates: ```js welcome: { subject: `Welcome aboard, ${item.name}!`, body: `<h2>Welcome to the newsletter!</h2><p>Hi ${item.name},</p><p>Thanks for confirming your subscription. Here's what to expect:</p><ul><li>Weekly insights and tips</li><li>Exclusive resources and guides</li><li>Early access to new content</li></ul><p>Stay tuned — your first tips are coming in a few days.</p>` }, tips: { subject: `3 tips to get you started, ${item.name}`, body: `<h2>Quick Start Tips</h2><p>Hi ${item.name},</p><p>Here are 3 tips to get the most from your subscription:</p><ol><li><strong>Reply to any email</strong> — we read every response</li><li><strong>Star our emails</strong> — so they never go to spam</li><li><strong>Share with a friend</strong> — help us grow the community</li></ol><p>More great content coming your way soon!</p>` } ``` Stored names and broadcast content are embedded directly into broadcast HTML: ```html <p>Hi {{ $json.name }},</p> {{ $json.content ...[truncated 2812 chars]
Remediation
<![CDATA[ ## Remediation Suggestions - HTML-escape every subscriber-controlled value at the point where it enters an HTML context. - Apply strict type and length limits to `name`, `source`, `email`, `subject`, and `content`. - Restrict `source` to a predefined allowlist of source identifiers. - Sanitize intentionally supported rich HTML using an allowlist that removes scripts, event handlers, forms, embedded objects, unsafe URL schemes, and unexpected remote resources. - Use plain-text email where rich HTML is unnecessary. - URL-encode token and email query parameters with `encodeURIComponent`. - Prefer opaque unsubscribe tokens over exposing email addresses in URLs. - Keep subject-header values free of control characters and enforce a conservative maximum length. - Test output against the sanitization behavior of supported email clients. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
workflows/02-welcome-sequence.json:44
Finding
Day-Zero Drip State Causes Repeated Welcome Emails<![CDATA[ ## Vulnerability Details **File Location**: `workflows/02-welcome-sequence.json:44, 197-199` **Vulnerability Type**: Non-idempotent workflow state and repeated email dispatch **Risk Level**: Medium ### Vulnerable Code ```js const now = new Date(); const subscribers = $input.all().map(i => i.json); const toSend = []; for (const sub of subscribers) { if (sub.status !== 'confirmed' && sub.confirmed !== 'true' && sub.confirmed !== true) continue; const subscribedAt = new Date(sub.subscribed_at); const daysSince = Math.floor((now - subscribedAt) / (1000 * 60 * 60 * 24)); const lastDrip = parseInt(sub.last_drip_day || '0', 10); // Drip schedule: Day 0 (welcome), Day 3 (tips), Day 7 (resources) let dripDay = null; let dripType = null; if (lastDrip < 0.5 && daysSince >= 0) { dripDay = 0; dripType = 'welcome'; } else if (lastDrip < 3 && daysSince >= 3) { dripDay = 3; dripType = 'tips'; } else if (lastDrip < 7 && daysSince >= 7) { dripDay = 7; dripType = 'resources'; } if (dripDay !== null) { toSend.push({ email: sub.email, name: sub.name || 'Subscriber', drip_day: dripDay, drip_type: dripType }); } } ``` After sending the Day-zero message, the same zero value is written back: ```json "value": { "email": "={{ $json.email }}", "last_drip_day": "={{ $json.drip_day }}", "last_drip_at": "={{ new Date().toISOString() }}" } ``` ### Technical Analysis The expression: ```js parseInt(sub.last_drip_day || '0', 10) ``` maps both an unset initial state and a stored Day-zero state to `0`. The Day-zero send condition accepts every value below `0.5`. After the welcome message is sent, `last_drip_day` is set to `0`, which still satisfies the same condition on the next scheduled run. Because the workflow runs every six hours, a confirmed subscriber can repeatedly receive the welcome message instead of advancing toward the Day-three state. This is an idempotency defect in an exte ...[truncated 984 chars]
Remediation
<![CDATA[ ## Remediation Suggestions - Represent “never sent” with an explicit state such as `null`, `-1`, or `not_started`, distinct from Day zero. - Test state values explicitly rather than relying on truthiness: ```js const hasDripState = sub.last_drip_day !== undefined && sub.last_drip_day !== null && sub.last_drip_day !== ''; const lastDrip = hasDripState ? Number.parseInt(sub.last_drip_day, 10) : -1; ``` - Send Day zero only when `lastDrip === -1`. - Record a unique delivery identifier or separate boolean field such as `welcome_sent`. - Make the operation idempotent by checking a durable delivery record before sending. - Consider reserving the delivery atomically before sending to prevent concurrent scheduler executions from sending duplicates. - Add automated tests for initial, Day-zero, Day-three, Day-seven, invalid, and missing-state transitions. - Add retry handling that distinguishes an SMTP failure from a successful send followed by a failed state update. ]]>
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
  • Trigger AbuseOverly Broad Trigger, Shadow Command Trigger, Keyword Baiting Trigger
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
Findings (11)

Missing User Warnings

Medium
Confidence
91% confidence
Finding
The skill describes collecting subscriber emails, names, sources, confirmation status, tokens, and analytics in Google Sheets, but it does not prominently warn users that personal data will be stored and used for automated campaigns and reporting. This creates a privacy and consent risk because operators may deploy the workflow without adequately informing subscribers or configuring appropriate data handling safeguards.

Autonomous Decision Making

Medium
Category
Excessive Agency
Content
## What It Does

1. **Double Opt-In Signup** — Webhook receives signups, validates email, sends confirmation link, stores in Sheets
2. **Welcome Drip Sequence** — Automatically sends Day 0 (welcome), Day 3 (tips), Day 7 (resources) emails
3. **Broadcast Sender** — API-triggered broadcast to all confirmed subscribers with unsubscribe links
4. **Daily Analytics** — Subscriber counts, growth metrics, confirmation rates, top sources
Confidence
80% confidence
Finding
Skill enables autonomous high-impact decisions without human-in-the-loop verification. Critical operations (destructive commands, financial transactions, data deletion) should require explicit user confirmation.

Missing User Warnings

Medium
Confidence
89% confidence
Finding
The broadcast feature is presented as a normal capability, but the documentation does not clearly warn that invoking it will send mass email to all confirmed subscribers. Without an explicit caution, an operator could trigger large-scale messaging unintentionally or without appropriate review, creating spam, reputational, and compliance risks.

External Transmission

Medium
Category
Data Exfiltration
Content
### 4. Test Signup
```bash
curl -X POST https://your-n8n.com/webhook/newsletter/signup \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "name": "Test User", "source": "api-test"}'
```
Confidence
60% confidence
Finding
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

Vague Triggers

Medium
Confidence
85% confidence
Finding
This is a JSON manifest/workflow file, so vague-trigger checks apply. The path "newsletter/signup" indicates activation on a generic signup POST endpoint, but the file provides no explicit constraints, negative examples, or scoping details about who may invoke it or under what contexts, which can make activation expectations ambiguous.

Missing User Warnings

Medium
Confidence
92% confidence
Finding
This workflow automatically sends email to subscriber addresses on a schedule without any built-in user-facing disclosure, approval checkpoint, or operational safeguard in the workflow itself. In a newsletter skill this behavior is expected, but if the subscriber sheet is populated incorrectly or abused, the system can send unsolicited messages at scale, creating spam, compliance, and reputation risk.

Vague Triggers

Medium
Confidence
84% confidence
Finding
This is a manifest/config file, so vague-trigger checks apply. The activation path "newsletter/broadcast" identifies a high-level action but provides no explicit constraints, negative examples, or narrower invocation context, which can make the skill easier to invoke unintentionally in systems that route by descriptive trigger names.

Missing User Warnings

Medium
Confidence
91% confidence
Finding
This workflow sends emails to subscriber addresses and includes personalized content, which is a safety-relevant network operation involving user data. In this file there is no confirmation step, user-visible logging, or inline warning/comment disclosing that subscriber email addresses and message content will be transmitted via SMTP.

Natural-Language Policy Violations

Low
Confidence
97% confidence
Finding
The workflow hard-codes the subject and HTML message in English. Under the natural-language policy check, forcing a specific language without offering user choice or documenting a justified locale constraint is a policy violation.

Vague Triggers

Low
Confidence
84% confidence
Finding
This is a manifest-style JSON file, so vague-trigger checks apply. The schedule is defined only as a 24-hour interval, but the file does not specify when it should run, under what conditions it should be disabled, or any limiting context, which can make invocation scope overly broad.

Natural-Language Policy Violations

Low
Confidence
67% confidence
Finding
SQP-3 only covers natural-language language or locale policy violations. The content here does not show a language/locale restriction, so this is only marginally applicable; however, there is no actual locale policy issue visible in the file.

Static analysis

No suspicious patterns detected.