# Access-model & security review

Passive, unauthenticated review of the Grant'd REST API surface as of 2026-04-23. No authenticated testing was performed, no fuzzing, no paid tier exercised. Goal: describe how access control works in practice, and flag anything worth a second look for the operator.

**Scope:** `https://grantd.com.au/wp-json/*` — all exposed REST namespaces.
**Out of scope:** authenticated endpoints, admin dashboard, login flow brute-forcing, DoS, payment layer, mobile app (if one exists).
**Disclosure posture:** Findings here are either already public-by-design or minor defender-side hygiene notes. Nothing warrants a pre-publication embargo. If the Grant'd team reads this and disagrees with any characterization, open an issue and we'll revise.

## Summary

| # | Area | Severity | Status |
|---|---|---|---|
| 1 | Title-only grant index is publicly enumerable | Informational | By design — plausibly intentional for SEO |
| 2 | Grant body & ACF fields are correctly gated server-side | — | Working as intended |
| 3 | CORS reflects any origin + `allow-credentials: true` | Low | Worth reviewing; mitigated by nonce requirement |
| 4 | `pmpro/v1/membership_levels` exposes all tiers, including 20+ internal/test tiers with names like `Fillter`, `Filler`, `Valentine`, `Trial Expired` | Low | Defender-side hygiene |
| 5 | `grantd/v1/mail/send-weekly-digest` reachable as a GET | Informational | Review authz; confirm it's cron-only |
| 6 | No rate-limit headers; Cloudflare fronting is the only limiter | Informational | Consider per-user quotas on auth'd routes |
| 7 | Empty taxonomy assignments on public grant responses | Informational | Consistent with the paywall model |

No auth bypasses, no injection, no IDOR, no exposed secrets were observed.

---

## 1. Title-only grant index is publicly enumerable

`GET /wp/v2/grant` returns 7,105 grants with `id`, `slug`, `title`, `link`, `date_gmt`, `modified_gmt`, and a handful of WP metadata fields. `content.rendered` is empty for every row.

### Details

- `X-WP-Total: 7105` confirms the full count is disclosed.
- Max `per_page=100` → the entire index can be enumerated in 72 requests.
- Sitemaps at `/sitemap_index.xml` → 8 grant sitemaps likewise expose the full permalink list with lastmod timestamps.

### Assessment

This is almost certainly intentional — Grant'd benefits from Google indexing grant titles, and the sitemap setup reinforces that. Disabling anonymous access to `/wp/v2/grant` is the only way to close it, which would also break any unauthenticated theme component that relies on the REST API.

### Suggested action (if Grant'd wants to reduce it)

If the business goal is "titles are SEO but should be harder to scrape programmatically":

1. Register a custom REST authentication requirement on `post_type=grant` (a `rest_grant_query` filter that returns `WP_Error` when `is_user_logged_in()` is false), *except* when the caller is Google/Bing — detectable via reverse-DNS on the UA. This is fragile but common.
2. Alternately, remove the `grant` post type from REST entirely (`'show_in_rest' => false`) and render grant pages via standard WP templates. The sitemap already covers SEO.

Personally, leaving it open is fine. The per-grant body is what has commercial value, and that is gated correctly (finding 2).

## 2. Grant body & ACF fields are correctly gated server-side

Every unauthenticated `/wp/v2/grant` response has `content.rendered: ""` and `acf: []`. Paid Memberships Pro filters at the `the_content` and ACF layers, and those filters run before JSON serialization.

### Verified

- 0 / 100 grants in a single page had non-empty `content.rendered`.
- Taxonomy `_embed` attempts return `[]` for every grant taxonomy (industry, sector, location, funding need, org type, SDG, LGA).
- `/grantd/v1/grants` (the rich list used by the logged-in UI) returns `401 rest_forbidden` without auth.

### Assessment

No action needed. This is the critical control and it holds.

## 3. CORS reflects any origin with `allow-credentials: true`

**Observed on `/wp-json/wp/v2/grant`, `/wp-json/grantd/v1/*` (via preflight), `/wp-json/pmpro/v1/*`:**

```
access-control-allow-origin: <Origin header value>
access-control-allow-credentials: true
access-control-allow-methods: OPTIONS, GET, POST, PUT, PATCH, DELETE
access-control-allow-headers: Authorization, X-WP-Nonce, Content-Disposition, Content-MD5, Content-Type
```

The server reflects whatever `Origin` the client sent — including `null`, `http://localhost:8000`, and arbitrary third-party domains — together with `allow-credentials: true`. This is the WordPress core default.

### What this means

A user who is logged into `grantd.com.au` and visits a malicious page in the same browser could, in theory, have that page make a cross-origin `fetch(url, { credentials: "include" })` call against the Grant'd API and read the response. The browser will send the session cookie and accept the reflected-origin response.

### What saves it in practice

- **Mutating endpoints are nonce-protected.** Every `POST`/`PUT`/`PATCH`/`DELETE` under `/grantd/v1/*` requires `X-WP-Nonce`. A malicious page cannot read the nonce (it lives inside the same-origin HTML behind SOP), so it cannot forge write operations.
- **Application Password auth uses `Authorization: Basic`**, not cookies. Cross-origin pages can't obtain or send an app password they don't know.

### Where the exposure is real

Authenticated **read** endpoints that rely on cookie auth alone without checking `X-WP-Nonce`. WordPress core's REST auth calls `rest_cookie_check_errors` which *does* require a valid nonce for any cookie-authenticated request, so in principle a cross-origin `fetch` with `credentials: "include"` but no nonce should 401. This is likely safe by default, but worth confirming with a live test:

```bash
# From a page on evil.example (or with -H "Origin: https://evil.example"):
curl -b cookies.txt \
  -H 'Origin: https://evil.example' \
  'https://grantd.com.au/wp-json/grantd/v1/dashboard/top-grants'
# Expected: 401 rest_cookie_invalid_nonce
# If it returns 200 with data, there's a gap.
```

### Suggested action

Either tighten CORS to an explicit allow-list (e.g. only `grantd.com.au`, `*.grantd.com.au`, and any first-party mobile origins), or confirm by test that every authenticated route under `/grantd/v1/*` calls `rest_cookie_check_errors` — or both. The current posture is almost certainly safe, but explicit is better than emergent.

## 4. `pmpro/v1/membership_levels` exposes all tiers, including internal ones

`/wp-json/pmpro/v1/membership_levels` returns **27 levels** (verified 2026-04-23), not the 4 a visitor sees on the pricing page. The extras include what look like internal / abandoned / test tiers:

| id | Name | Amount | Notes |
|---:|---|---:|---|
| 6,7 | Changemaker | A$97/mo, A$987/yr | Not advertised; `allow_signups: 0` |
| 8,9 | Founding Member | A$201.82/yr, A$53.64/mo | Possibly launch-era tier |
| 10–13 | Seeker (extra variants) | A$270/yr, A$80.91/mo, A$403.64/yr, A$107.27/mo | Price duplicates / old grandfathered pricing |
| 14 | FIA Free | A$0 | Partner tier |
| 15 | Valentine | A$0 | Promo code? |
| 16 | Professional | A$226.36 | Legacy? |
| 17,18 | Trial, Trial Expired | A$0 | Lifecycle states |
| 19–23 | More Seeker variants | A$0.91–A$241.82 | Unclear |
| 24 | Fillter (sic) | A$0 | Typo; likely test data |
| 25 | Filler | A$0 | Test data |
| 26 | Free | A$0 | |
| 27,28 | Seeker | A$24.55, A$270 | Likely legacy |

This is PMPro's default behaviour — every level defined in the admin is returned. Three concerns:

1. **Leaked business context.** The presence of grandfathered pricing, promo tiers (`Valentine`), and pre-launch lifecycle states (`Founding Member`) tells competitors and journalists things the operator probably didn't intend to publish — especially price-duplication across `Seeker` variants, which suggests the A$201.82 number is not actually what early customers pay.
2. **Test artefacts in production.** `Fillter`, `Filler`, `Trial Expired` look like staging fixtures that were never cleaned up. They don't break anything but they're visible in a public JSON endpoint on the marketing site.
3. **Amount `$0.91` variants** (ids 19, 20) look like test-gateway or fraud-probe leftovers — PMPro stores past test payments. Harmless, but messy.

### Suggested action

Use `GET /pmpro/v1/checkout_levels` for the public-facing list (it correctly returns "No levels found." without auth, i.e. intended to be consumed from the server with checkout context). Either:

- Delete the legacy / test PMPro levels in the admin, or
- Filter the REST response by adding a `pmpro_rest_api_get_membership_levels` filter that only returns levels with `allow_signups == 1` for unauthenticated callers.

## 5. `grantd/v1/mail/send-weekly-digest` reachable as a GET

The endpoint is registered with `methods: GET` and permission callback requiring auth (returns 401 unauth). That's correct. **But** if any authenticated user can trigger it, an attacker with a valid session could use it as a low-cost way to spam outbound mail or hit a downstream mail service.

### Suggested action

Confirm that the permission callback restricts to `manage_options` (admin) or to a cron-only caller (`DOING_CRON` check). If it's meant purely to be called by WP Cron, consider unregistering the REST route entirely and scheduling the digest via `wp_schedule_event()`.

Not tested from an authenticated session; flagged as a pattern worth a second look.

## 6. No rate-limit headers

No `X-RateLimit-*`, `Retry-After`, or similar headers observed on either public or preflight responses. Cloudflare's `__cf_bm` bot-management does front the site, but it's tuned for obvious bots, not credentialled integrators.

### Suggested action

If Grant'd plans to formalize API access for integrators, publish a per-account quota (e.g. N requests / hour per app password) and return `429` with `Retry-After` when exceeded. The data in `pmpro/v1/me` already identifies the tier; tying rate limits to tier is straightforward.

Without a published quota, integrators will guess conservatively (and likely under-use the API) or guess aggressively (and get Cloudflare-challenged at surprising moments).

## 7. Empty taxonomy assignments on public grant responses

Taxonomy *vocabularies* (`grant_industry`, `grant_location`, etc.) are enumerable unauthenticated, but every term has `count: 0` and no grant's public response lists any assigned terms. This is internally consistent with the paywall model — if you showed assignments publicly, third parties could infer grant eligibility from metadata alone.

### Assessment

Working as intended.

---

## Non-findings (things that are fine)

- **No user enumeration** observed via `/wp-json/wp/v2/users` on first anonymous attempt (endpoint not probed deeply; worth a follow-up).
- **No exposed secrets** in theme JS (`project-dropdown.js`, `user-grant-status.js`) — just nonce plumbing.
- **Session nonce** lifetime and scope look like WP defaults.
- **Sentry DSN** is embedded in `wp-sentry-init.js` — standard for frontend error tracking, not a secret.
- **Login form** behaves like stock WP; no obvious username disclosure on incorrect-password errors tested.
- **Cloudflare `__cf_bm`** challenges don't appear to fire on ordinary curl traffic under ~2 rps.

## Things not tested

- Authenticated endpoint behaviour — every `/grantd/v1/*` test was anonymous.
- IDOR on `/grantd/v1/grant-user-data/<id>/*` — is any user's grant_id accessible, or is it scoped to the caller? Needs a paid account to verify.
- Admin-AJAX surface (`/wp-admin/admin-ajax.php`).
- Upload path (`/grantd/v1/users/me/avatar` POST).
- Discount code endpoints under `/pmpro/v1/discount_code`.
- Whether `/grantd/v1/mail/send-weekly-digest` is actually callable by a non-admin authenticated user.
- Fuzzing of `_fields`, `search`, `orderby`, `meta_query` params for potential filter bypasses.

## Artefacts

Captures and raw responses from the probe live in the original private audit workspace (`security-audits/targets/grantd/recon/`). Nothing sensitive is reproduced here; everything above is derivable from unauthenticated HTTP.

## Responsible disclosure

If the Grant'd operator wants to address any of the low-severity items, the simplest contact is a GitHub issue on this repo, or an email to the security contact at grantd.com.au. This document will be updated to reflect any corrections.
