Xgenious/ docs

Security & Best Practices

A plugin runs with the same privileges as core. A mistake in a plugin is a mistake in the site. This page lists the rules that matter most and the patterns that keep a plugin safe.

The ten rules

  1. Escape all output.
  2. Validate all input and never trust the context array.
  3. Never store secrets in the repository.
  4. Never let a filter change ownership, status, or credentials.
  5. Make write actions idempotent.
  6. Use transactions for multi-row writes.
  7. Gate outbound requests against SSRF.
  8. Check permissions on every admin route.
  9. Fail closed for security, open for availability, and log both.
  10. Keep boot() free of I/O.

Output escaping

Slots return HTML. If you interpolate user data, escape it.

// Wrong
return '<div>' . $context['name'] . '</div>';

// Right
$name = htmlspecialchars((string) ($context['name'] ?? ''), ENT_QUOTES, 'UTF-8');
return '<div>' . $name . '</div>';

Blade escapes {{ }} automatically:

<div>{{ $name }}</div>

Raw output requires intent:

{!! $trustedHtml !!}

Use {!! !!} only for HTML you generated and sanitized yourself. Never for user input.

URLs

$url = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');

Also validate the scheme. Allow http and https; reject javascript:, data:, and file:.

Attributes

Quote attributes and escape them:

return '<a href="' . $url . '" title="' . $title . '">' . $label . '</a>';

An unquoted attribute can break out with a space.

JSON in HTML

Use json_encode() with the right flags, then escape for the HTML context:

$json = json_encode($data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);

Input validation

The context array is not trusted. It comes from core, but a future core change or a bad caller can put anything in it.

$orderId = isset($context['order_id']) ? (int) $context['order_id'] : 0;
if ($orderId <= 0) {
    return '';
}

Rules:

  • Cast scalars to the expected type.
  • Whitelist enum values.
  • Reject arrays where a scalar is expected.
  • Check array keys with array_key_exists, not isset, when null is meaningful.
  • Limit string length before storing or rendering.

Data filters and the allow-list

Core calls:

plugin_filter_model_data($data, 'job.create_data', $allowed, $context);

The helper:

  1. Runs your filter.
  2. Intersects the result with $allowed.
  3. Re-asserts the original values for protected keys.

You cannot change user_id, status, password, is_hired, or similar fields. If your filter returns a non-array, the original data is used.

Do not try to bypass this by writing directly to the database from a filter. Use an action after the write, or ask core for a legitimate seam.

Secrets

Never commit:

  • API keys.
  • Gateway secrets.
  • SMTP passwords.
  • Webhook signing secrets.
  • Private keys.
  • OAuth client secrets.

Store them as static options, encrypted config, or environment variables. Mask them in the admin UI:

<input type="password" name="api_key" value="<?php echo $hasKey ? '********' : ''; ?>">

On save, ignore the masked placeholder and keep the existing value.

Logging

Never log:

  • Passwords or password hashes.
  • OTP codes.
  • Session IDs or tokens.
  • Full card numbers.
  • API keys.
  • Personal data you do not need.

Mask the middle of an email or key if you need to identify it:

$masked = substr($email, 0, 2) . '***' . substr($email, strpos($email, '@'));

Capabilities and least privilege

Declare only the capabilities you use:

"capabilities": ["outbound-https"]

Ask for the smallest permission scope. An admin route that only reads should use a -view permission, not -manage. A user route should check ownership, not just authentication.

$order = Order::findOrFail($orderId);
if ((int) $order->user_id !== (int) auth()->id()) {
    abort(403);
}

Authentication proves who you are. Authorization proves you may act on this record.

CSRF and API routes

  • routes/web.php is inside the web group, so it has CSRF protection. Keep it.
  • routes/api.php has no CSRF and no session. Authenticate explicitly and verify signatures.
  • Never disable CSRF for a browser form.
  • For a webhook, use a signature and a timestamp, not a shared session.

SQL injection

Use bindings. Never concatenate input into SQL.

// Wrong
DB::select("select * from my_plugin_items where label = '$label'");

// Right
DB::select('select * from my_plugin_items where label = ?', [$label]);

The query builder and Eloquent bind by default. Raw expressions need care.

File uploads

  • Validate the extension and the MIME type.
  • Generate the stored filename; never trust the client's.
  • Store outside the web root, or in a directory with execution disabled.
  • Limit size and count.
  • Scan or reject executable types.
  • Do not trust the Content-Type header alone.

SSRF

Any plugin that fetches a URL on behalf of a user is an SSRF risk. A user can point it at http://127.0.0.1:6379 or a cloud metadata endpoint.

Rules:

  1. Allow only http and https.
  2. Resolve the hostname and reject private, loopback, link-local, and reserved ranges.
  3. Re-check after redirects; do not follow redirects blindly.
  4. Set a short timeout and a response size limit.
  5. Do not return the raw response body to the user if it may contain internal data.

Private ranges to block:

10.0.0.0/8
172.16.0.0/12
192.168.0.0/16
127.0.0.0/8
169.254.0.0/16
::1/128
fc00::/7
fe80::/10

The Webhooks plugin implements this guard. Copy its approach rather than inventing one.

Webhook signing

If you send webhooks:

  • Sign the raw body with HMAC-SHA256.
  • Include a timestamp and reject old timestamps.
  • Use a per-endpoint secret.
  • Include a delivery ID for idempotency.
  • Retry with backoff.
  • Log the response status and a truncated body.
  • Never retry a 4xx except 408 and 429.

If you receive webhooks:

  • Verify the signature before parsing the body.
  • Use hash_equals() for comparison.
  • Reject replays by delivery ID.
  • Respond 2xx quickly, then process asynchronously.

Idempotency

Actions can fire more than once in edge cases: a retried IPN, a double click, a queue retry.

Make writes idempotent:

DB::table('my_plugin_ledger')->updateOrInsert(
    ['order_id' => $orderId, 'kind' => 'commission'],
    ['amount' => $amount, 'updated_at' => now()]
);

Or use a unique index and catch the duplicate:

try {
    DB::table('my_plugin_ledger')->insert([...]);
} catch (\Illuminate\Database\QueryException $e) {
    // Already recorded.
}

For payments, key on the transaction ID, not the amount and date.

Transactions

Use a transaction when a single logical change touches more than one row:

DB::transaction(function () use ($orderId): void {
    DB::table('my_plugin_items')->insert([...]);
    DB::table('my_plugin_ledger')->insert([...]);
});

Do not send email or call an external API inside a transaction. Do it after the commit.

Race conditions

Two requests can arrive at once. Use:

  • A unique index for uniqueness.
  • lockForUpdate() for read-modify-write.
  • Compare-and-set updates for state machines.
  • A queue with a single worker for serial work.

Freelancer Level's freelancer.level.changed action uses a compare-and-set write so concurrent order completions cannot double-fire a level change.

Rate limiting and abuse

  • Rate-limit public endpoints.
  • Cap input length before calling an AI provider.
  • Cap output length before storing.
  • Add a cooldown to expensive actions.
  • Do not let an unauthenticated request trigger an email or SMS.

AI Power's frontend guardrails are a good reference: rate limit, input cap, output cap, and contact filtering.

Fail open or fail closed

AreaBehaviorReason
Phone OTPFail open if the SMS plugin is missingOtherwise every user is locked out
Security scanFail open with a log entryDo not block checkout on a scanner outage
Payment verificationFail closedNever credit money on an unverified callback
Permission checkFail closedDeny by default
Webhook signatureFail closedReject unsigned requests
VIES lookupFail open, mark unverifiedDo not block profile saves

Document the choice in the plugin README.

Privacy

  • Collect only what you need.
  • Tell the operator what you store and where.
  • Provide a delete path for user data.
  • Do not copy core personal data into a plugin table without a reason.
  • Do not send personal data to a third party without the operator's configuration and consent.
  • Support log retention and pruning if you write logs.

Internationalization

  • Wrap user-facing strings in __().
  • Use the plugin's translation namespace.
  • Do not concatenate sentences; use placeholders.
  • Format dates and numbers with the site's locale.
  • Test right-to-left layouts if the site supports RTL.
__('my-plugin::messages.welcome', ['name' => $name])

Performance

  • Do no I/O in boot().
  • Use plugin_has_filter() in core, not in the plugin, to skip expensive payloads.
  • Cache expensive computations per request, not globally.
  • Paginate large queries.
  • Add indexes for columns you filter on.
  • Queue slow work.
  • Keep slot output small.
  • Avoid loading a large JS bundle on every page.

Accessibility and UX

  • Use semantic HTML.
  • Label every input.
  • Keep focus states visible.
  • Do not rely on color alone.
  • Support keyboard navigation.
  • Show errors next to the field.
  • Confirm destructive actions.
  • Do not auto-play audio.

Security review checklist

  • All output escaped.
  • All input cast and validated.
  • No secrets in the repository.
  • No direct writes to core tables.
  • Admin routes permission-gated.
  • Ownership checks on user routes.
  • CSRF preserved on web forms.
  • Webhook signatures verified.
  • Outbound URLs SSRF-guarded.
  • Writes idempotent.
  • Multi-row writes in a transaction.
  • Logs contain no secrets or OTPs.
  • Rate limits on public endpoints.
  • Failure behavior documented.
  • Dependencies reviewed.

Common vulnerabilities and fixes

VulnerabilityExampleFix
XSSSlot prints a name unescapedhtmlspecialchars
SQL injectionString interpolation in a queryBindings
IDOROrder route trusts the IDCheck ownership
CSRFCustom form without a tokenKeep the web group
SSRFUser-supplied webhook URLBlock private ranges
Open redirectredirect($request->input('next'))Allow-list paths
Mass assignmentModel::create($request->all())Explicit fields
Path traversalZip extraction with ../Validate entry paths
ReplayWebhook accepted twiceDelivery ID + timestamp
RaceDouble creditUnique index or lock

Last updated: October 21, 2026

Still stuck?
Our support team is ready to help you get set up.
Get support