WP Manifestindependent plugin directory
manifest / content / apermo-stash

Apermo Stash WP Registry grade B. Medium-severity findings · 1 findingWP RegistryMedium-severity findings · 1 findingOpen the reportB

Apermo Stash — a self-hosted WordPress bookmark collection with a token-protected REST API.

★ 1stars
846composer installs
v0.2.2latest · 4mo ago

Install

Download source zip No release zip yet

This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.

Install straight from GitHub with WP-CLI:

wp plugin install https://github.com/apermo/apermo-stash/archive/refs/heads/main.zip

On Packagist as apermo/apermo-stash:

composer require apermo/apermo-stash
No built-in updater. New versions won't appear under Dashboard → Updates. Install new releases yourself, or let Git Updater track the repository.

A self-hosted WordPress plugin for collecting links. Inspired by linkding. Stores URL + title + notes + tags as a custom post type and exposes a token-protected REST API so a browser extension can save links from anywhere.

Per-link public/private visibility, idempotent save (safe to re-submit), and CORS configured for chrome-extension://* origins out of the box.

Requirements

  • PHP 8.1+
  • WordPress 6.4+
  • Composer (development only — runtime has no Composer dependencies)
  • Node.js 20+ and npm (activates husky pre-commit hook, runs Playwright)
  • DDEV (for local development)

Installation

  1. Clone or download this repository into wp-content/plugins/apermo-stash/.
  2. Run composer install --no-dev to generate the autoloader.
  3. Activate the plugin through the WordPress "Plugins" screen.
  4. Visit Settings → Apermo Stash to generate an API token (see Authentication below).

Authentication

Apermo Stash accepts two equivalent authentication schemes; pick whichever fits your client.

WordPress Application Passwords (Basic Auth)

Available in WordPress core. Generate one under Users → Profile → Application Passwords and pass it as Basic Auth:

curl -u "your-username:xxxx xxxx xxxx xxxx xxxx xxxx" \
     https://example.tld/wp-json/apermo-stash/v1/links

Apermo Stash Bearer Tokens

Better suited for browser extensions: generate at Settings → Apermo Stash → API Tokens. The plain token is shown once at creation time — copy it immediately. Send it as:

curl -H "Authorization: Bearer <token>" \
     https://example.tld/wp-json/apermo-stash/v1/links

Each token is bound to a WordPress user; permission checks run against that user's capabilities (edit_posts for write endpoints).

REST API

Base path: /wp-json/apermo-stash/v1.

Method Path Description
GET /links List links (filters: tag, q, unread, archived, public/private, page, per_page)
POST /links Create a link (idempotent — same URL returns existing record with X-Apermo-Stash-Existing: 1)
GET /links/{id} Fetch a single link
PATCH /links/{id} Update fields
DELETE /links/{id} Delete a link
GET /tags List tags with link counts
GET /check?url=... Returns {exists: bool, id?: int} for a given URL

Examples

Save a link; let the server fetch the title and description:

curl -X POST https://example.tld/wp-json/apermo-stash/v1/links \
     -H "Authorization: Bearer <token>" \
     -H "Content-Type: application/json" \
     -d '{"url":"https://example.tld/article","tags":["reading"],"public":true}'

Check whether a URL is already saved (browser-extension "already saved" badge):

curl -H "Authorization: Bearer <token>" \
     "https://example.tld/wp-json/apermo-stash/v1/check?url=https://example.tld/article"

Search and filter:

curl -H "Authorization: Bearer <token>" \
     "https://example.tld/wp-json/apermo-stash/v1/links?tag=reading&unread=1"

Public versus private links

Links use WordPress's native post_status:

  • publish (public) — readable without authentication via the REST API.
  • private — only the owner (and users with edit_others_posts) can read.

Anonymous GET /links returns only public links. Authenticated users see their own links plus any public links owned by other users. POST, PATCH, DELETE always require authentication.

CORS

By default Apermo Stash sends CORS headers permitting chrome-extension://* origins. Add additional origins via the apermo_stash_allowed_origins filter:

add_filter( 'apermo_stash_allowed_origins', static function ( array $origins ): array {
    $origins[] = 'https://my-frontend.example.tld';
    return $origins;
} );

To narrow the default allow-list once you know your extension's specific ID — defense-in-depth on top of the Bearer requirement — return only that origin:

add_filter( 'apermo_stash_allowed_origins', static function (): array {
    return [ 'chrome-extension://abcdefghijklmnopqrstuvwxyzabcdef' ];
} );

Outbound HTTP

Apermo Stash makes one outbound HTTP request per saved link — to the saved URL itself, via wp_safe_remote_get (5 s timeout, up to three redirects, all re-validated). The fetched body is parsed for <title> and <meta name="description" / og:description>; on failure the link still saves and an "unreachable" warning is shown on next edit. wp_safe_remote_get blocks loopback and private IP ranges, so a hostile URL can't be used to probe internal services.

No third-party services are contacted. No analytics, no telemetry. The companion Chrome extension talks only to the host you configure on its options page.

Development

composer install
npm install               # activates husky pre-commit hook
composer cs               # PHPCS
composer cs:fix           # PHPCBF
composer analyse          # PHPStan
composer test:unit        # unit tests (Brain Monkey)
composer test:integration # integration tests (wp-phpunit)
npm run test:e2e          # Playwright E2E

Local WordPress environment

ddev start && ddev orchestrate

License

GPL-2.0-or-later

Releases

7 releases.

Tag
Published
v0.2.2 latest
Jun 21, 2026 4mo ago
May 25, 2026 5mo ago
May 10, 2026 5mo ago
May 4, 2026 5mo ago
May 3, 2026 5mo ago
May 3, 2026 5mo ago
May 2, 2026 5mo ago

These releases are tags only. The author does not attach a packaged zip, so there are no download counts to report.

B grade

Security

Apermo Stash 0.2.2 · audited by WP Registry

Medium-severity findings.

1 medium
Audited release
0.2.2
Findings
1
Worst severity
medium
Content hash
c157f7330983ecfc375c97f4…

Findings

  • medium Unauthenticated listing of public stash links via core REST /wp/v2/links

    information_disclosure

    v0.2.0 (PR #48, wp.org Plugin Review) switched GET /apermo-stash/v1/links and GET /tags from allow_anyone to require_read_links (current_user_can('edit_posts')) because 'public access isn't an MVP requirement' and 'all reads' were supposed to match writes. The CPT is still registered with public/publicly_queryable false but show_in_rest true and rest_base 'links', and LinkMeta registers _apermo_stash_url / _apermo_stash_url_canonical / _apermo_stash_favorite / _apermo_stash_unreachable with show_in_rest true. Core WP_REST_Posts_Controller::get_items_permissions_check returns true for unauthenticated callers (only context=edit is gated), and check_read_permission returns true for post_status=publish without checking is_post_type_viewable(). Result: GET /wp-json/wp/v2/links dumps every published (UI 'Public') stash link — title, notes (post_content), author, dates, tag IDs, and the URL meta — with no nonce or capability check. Private links are not returned. The block editor is already disabled for this CPT, so show_in_rest is not required for the admin UI. Taxonomy apermo_stash_tag is also show_in_rest true (rest_base 'tags'), so term IDs appear on each post payload.

    src/PostType/LinkPostType.php:87-96

    Recommendation

    Set show_in_rest to false on apermo_stash_link and apermo_stash_tag (the plugin already uses a custom apermo-stash/v1 controller and disables the block editor). If core REST must stay, supply a rest_controller_class whose get_items/get_item permission_callback requires edit_posts (same as Permissions::require_read_links) and do not register underscore meta with show_in_rest unless that callback passes. Add a regression test that anonymous GET /wp/v2/links is 401/403.

WP Registry hashes the installable build and reports on that exact bytes-for-bytes copy. Embargoed findings are withheld until they are disclosed, so a clean verdict means nothing public is outstanding. WP Manifest does not audit code itself.