# Social Linkz — security audit

**Date:** 2026-08-29
**Scope:** `social-linkz.php`, `lite/includes/**`, `pro/includes/**`. The bundled
Freemius SDK (`libs/fs/`) is third-party and was not audited; keeping it current
is tracked separately.
**Version audited:** 1.8.10. Everything marked *Fixed* landed in 1.9.0.

## Summary

Nothing here is remotely exploitable by an unauthenticated visitor. The two
findings that matter are a missing authorisation check on a `GET` handler
(**S-01**) and the absence of any sanitisation on stored settings, which reach a
`<style>` block on every visitor's page (**S-02**). The rest are hardening and
correctness.

| ID | Severity | Issue | Status |
|----|----------|-------|--------|
| S-01 | Medium | Notice dismissal wrote an option named from the URL, with no capability check and no nonce | Fixed |
| S-02 | Medium | Settings stored unsanitised, then printed into front-end CSS | Fixed |
| S-03 | Medium | Share button markup built by string concatenation without escaping | Fixed |
| S-04 | Low | Tools screen exposed at the `read` capability | Fixed |
| S-05 | Low | Schema update handler checked a nonce but not a capability | Fixed |
| S-06 | Low | Settings framework echoed names, ids, choices and descriptions unescaped | Fixed |
| S-07 | Low | `Helper::get_current_url()` built a URL from `SERVER_NAME` and `REQUEST_URI` | Fixed |
| S-08 | Low | `$_SERVER` values sent to the telemetry endpoint unsanitised | Fixed |
| S-09 | Info | `Helper::get_ip()` trusts forwarded-for headers | Open — documented |
| S-10 | Info | `remove_all_actions( 'admin_notices' )` on the plugin dashboard | Open — roadmap |
| S-11 | Info | `DELETE` over `wp_options` built by string concatenation | Fixed (was not injectable) |

---

## S-01 — Notice dismissal: missing authorisation (Medium)

`Admin::dismiss_admin_notice()`, hooked on `admin_init`, ran for **any**
logged-in user:

```php
if ( isset( $_GET['kc_sl_dismiss_admin_notice'] ) && $_GET['kc_sl_dismiss_admin_notice'] == '1' && isset( $_GET['option_name'] ) ) {
    $option_name = sanitize_text_field( $_GET['option_name'] );
    update_option( 'kc_sl_' . $option_name . '_dismissed', 'yes', false );
```

No capability check, no nonce, and the option name came from the query string.
A subscriber — or anybody who could get an administrator to follow a link —
could create arbitrary `kc_sl_<anything>_dismissed` rows in `wp_options`. Not
privilege escalation on its own (the value is always the string `yes`, and the
key is prefixed), but it is unauthenticated-ish option writing and an
uncontrolled row count in `wp_options`.

**Fixed** by requiring `manage_options`, `check_admin_referer()`, and an
allowlist of notice keys (`Admin::dismissible_notices()`). `Admin::get_dismiss_notice_url()`
is the signed URL builder notices should use.

## S-02 — Settings stored unsanitised (Medium)

`Admin\Settings::validate_settings()` was a no-op, and the framework's
`settings_validate()` only ran a filter. Whatever was posted to `options.php`
was stored verbatim under `kc_sl_settings`.

That matters because those values are printed into a `<style>` block on every
page view — `Helper::print_button_inline_styles()` interpolated
`design|button_color` straight into a declaration. A stored value of
`red;}body{display:none` closes the rule and opens another. Escaping does not
help here: `esc_attr()` leaves `}` alone.

**Fixed** two ways, so neither is the only line of defence:

- `Settings::sanitize()` now walks the submitted array against the registered
  field definitions and sanitises per field type — selects and checkbox groups
  must name a choice the form actually offered, colours go through
  `sanitize_hex_color()`, numbers through `(int)`.
- `Helper::sanitize_css_color()` and `Helper::sanitize_css_length()` drop
  anything that is not a colour or a length at the point of output.

A group with no registered fields (a PRO tab on a build whose licence has
lapsed) is **kept** and scrubbed with `Helper::clean()` rather than dropped —
dropping it would silently wipe those settings the first time somebody saved a
different tab.

## S-03 — Unescaped share button markup (Medium)

`Helper::print_buttons()` concatenated the href, `aria-label`, class list and
every attribute contributed by the `kc_sl_button_attributes` filter with no
escaping, and interpolated the copy-button URL into an inline `onClick`.

**Fixed:** `esc_url()`/`esc_attr()` on every attribute, `esc_html()` on labels,
`wp_json_encode()` for the value embedded in JavaScript, `wp_kses_post()` on the
call-to-action, and network icons through an SVG-aware `wp_kses()` allowlist
(`Helper::get_network_icon()`) since they arrive via a filter.

## S-04 — Tools screen at `read` (Low)

`add_submenu_page( …, 'read', 'social-linkz-tools', … )` put a plugin
configuration screen in front of every subscriber. **Fixed:** `manage_options`,
matching the rest of the plugin.

## S-05 — Schema update handler (Low)

`Install::install_actions()` verified a nonce but never asked whether the user
could run a database update. `admin_init` fires for every logged-in user.
**Fixed:** `current_user_can( 'manage_options' )` before either branch. (The
redirect target was also a leftover from URL Shortify — `us_dashboard` — and now
points at this plugin's own page.)

## S-06 — Settings framework output (Low)

Every `generate_*_field()` echoed `$args['name']`, `$args['id']`,
`$args['class']`, `$args['placeholder']` and the choice labels unescaped, and
`generate_description()` echoed the description raw. Administrator-only screens,
so low severity, but it is exactly what a WordPress.org review flags.

**Fixed:** attributes escaped with `esc_attr()`, labels with `esc_html()`,
descriptions and switch/checkbox labels with `wp_kses_post()` — they legitimately
carry `<code>` and links.

## S-07 — `Helper::get_current_url()` (Low)

Built from `$_SERVER['SERVER_NAME']` and `$_SERVER['REQUEST_URI']`, both
request-controlled on most setups, and used to build the feedback notice's
action links. **Fixed:** `home_url( esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) )`,
so the host always comes from the site's own configuration.

## S-08 — Telemetry `$_SERVER` values (Low)

`Tracker::get_server_info()` sent `SERVER_SOFTWARE` and `HTTP_USER_AGENT`
straight through. **Fixed:** `sanitize_text_field( wp_unslash( … ) )` with
`isset()` guards.

## S-09 — `Helper::get_ip()` trusts proxy headers (Informational, open)

The method walks `HTTP_CF_CONNECTING_IP`, `HTTP_X_REAL_IP`, `HTTP_CLIENT_IP`,
`HTTP_X_FORWARDED_FOR` … before `REMOTE_ADDR`. Every one of those is
attacker-supplied unless a trusted proxy sets it.

Nothing in the plugin currently makes a security decision from the result, so
this is not exploitable today. It becomes a real finding the moment IP is used
for rate limiting, click deduplication or geo-targeting — all of which are on
the roadmap. Left as-is with this note rather than changed blind, because
narrowing it would break sites genuinely behind Cloudflare.

**Recommendation:** before the first feature that consumes it, add a
`KC_SL_TRUSTED_PROXY_HEADER` constant and read only the named header, defaulting
to `REMOTE_ADDR`.

## S-10 — `remove_all_actions( 'admin_notices' )` (Informational, open)

`Admin::remove_admin_notices()` removes *every* admin notice on the plugin's
dashboard page, and elsewhere on the plugin's screens unsets other plugins'
callbacks from `$wp_filter` by inspecting their internals. Not a vulnerability,
but it suppresses other plugins' security and update warnings on those screens,
and reaching into `$wp_filter` is fragile. Tracked on the roadmap as a
targeted-suppression rewrite.

## S-11 — Concatenated `DELETE` (Informational, fixed)

`Install::delete_update_transient()` built a `DELETE FROM …options WHERE
option_name LIKE '…'` by interpolation. All five patterns are plugin-authored
constants passed through `$wpdb->esc_like()`, so nothing user-supplied ever
reached the query and it was not injectable. Rewritten with `$wpdb->prepare()`
regardless, and pointed at `$wpdb->options` rather than
`{$wpdb->prefix}options`.

---

## Checks that came back clean

- **SQL.** One direct query, in `Install::delete_update_transient()` — a
  `DELETE` over `wp_options` whose five `LIKE` patterns are all plugin-authored
  constants run through `esc_like()`, so no request data ever reached it. It has
  been rewritten with `$wpdb->prepare()` anyway. Everything else goes through
  the options and post-meta APIs; `Activator` and `Deactivator` already used
  `prepare()` for their multisite blog-id lookups.
- **AJAX / REST.** No `wp_ajax_*`, `admin_post_*` or `register_rest_route()`
  handlers, so there is no unauthenticated endpoint surface.
- **File handling.** No uploads, no `file_get_contents()` on request input, no
  `include`/`require` built from request data.
- **Deserialisation.** No `unserialize()` on stored or request data.
- **Capability model.** Every registered screen is now `manage_options`; the
  meta box save path checks `edit_post` for the specific post plus a nonce.
- **Post meta.** `_kc_sl_details` is written only through `MetaBox::save()`,
  which requires a nonce, `edit_post`, and validates that a supplied image ID is
  really an attachment.
