# Security and Hardening Guide

This guide is for security-conscious WordPress administrators running **Open Access SSO** (version 2.1.4 and later). It describes the protections the plugin enforces by default, the security-relevant settings you can adjust, a recommended hardened configuration, and how to recover access if you lock yourself out.

The plugin is privacy-first: it sends **no** telemetry, analytics, or phone-home traffic and loads **no** external CDN assets. For a SAML IdP, the only outbound HTTP requests are the admin-initiated IdP-metadata fetch (XML by URL) and the opt-in per-IdP certificate-rotation cron that re-fetches that same metadata URL. For an OpenID Connect provider, three more outbound calls exist and — unlike the SAML metadata fetch — happen during an unauthenticated login rather than an admin action: discovery, JWKS refresh, and UserInfo (see [section 5](#5-openid-connect-280)). All configuration is stored in `wp_options`; the SP private keys are encrypted at rest.

---

## 1. Built-in protections (always on)

These behaviors are enforced automatically and require no configuration. They apply to every SAML message the plugin processes at its public endpoints (the ACS and SLO URLs).

### Signature verification and the RSA algorithm allowlist
Every SAML Response/assertion (and signed logout message) must carry a valid XML signature from the configured IdP certificate. The plugin accepts **only** RSA-SHA256, RSA-SHA384, and RSA-SHA512 signatures. RSA-SHA1 is refused unless you explicitly opt in (see `allow_sha1_fallback` below). This holds for both the HTTP-POST and HTTP-Redirect bindings.

### Mandatory assertion Issuer
The assertion must contain an `Issuer`, and it is matched against the configured IdP Entity ID. An assertion with a missing or mismatched issuer is rejected.

### AudienceRestriction enforcement
By default, the assertion must contain an `AudienceRestriction` that names this site's SP Entity ID. This prevents an assertion minted for a different service from being replayed against your site. Controlled by `require_audience` (default on).

### Assertion-expiry enforcement
By default, the assertion must carry an enforceable expiry (a `NotOnOrAfter` in its `Conditions` or `SubjectConfirmationData`). Assertions with no expiry window are rejected so they cannot be replayed indefinitely. Controlled by `require_assertion_expiry` (default on).

### Replay protection
Each Response's `InResponseTo` / message ID is recorded in a database-backed replay store (with a legacy transient fallback) so a captured assertion cannot be submitted twice. A daily cron (`oasso_cleanup_replay_cache`) prunes expired entries. Clock-skew tolerance for timestamp checks is configurable (default 120 seconds, clamped 0–600).

### XML-DoS / DOCTYPE rejection, size cap, and bounded inflate
All IdP-supplied XML is parsed through a hardened routine that **rejects any DOCTYPE/DTD** (checked both by byte-scan and after parsing). This blocks internal-entity-expansion ("billion laughs") denial-of-service attacks (CWE-776) on top of WordPress/libxml's XXE and no-network defenses. In addition:
- Incoming SAML messages are subject to a **256 KB size cap** (checked before base64-decode; filterable via `oasso_max_saml_message_bytes`).
- Redirect-binding payloads are inflated with a **bounded DEFLATE** routine to stop decompression bombs (CWE-409).

### Encrypted-assertion decryption allowlist
For IdPs that encrypt assertions or NameIDs, the plugin restricts decryption to safe algorithms: **RSA-OAEP** key transport (with SHA-1/256/384/512 digests, including the SHA-256 digest used by NetIQ Access Manager and other modern IdPs) and **AES-CBC/GCM** data ciphers. Weak legacy algorithms — RSA-1.5 key transport and 3DES — are refused unless you explicitly opt in (see `allow_legacy_decryption_algorithms`).

### Single-use wrapped session key (encrypted assertions)
Every `EncryptedKey` (the RSA-wrapped AES session key inside an encrypted assertion) is fingerprinted and checked against the replay store **before** the private key is used to unwrap it, and rejected on reuse. This closes off an AES-CBC decryption-oracle attack: such an attack needs thousands of adaptive queries against ciphertext derived from one captured wrapped key, and this plugin caps that key at exactly one use regardless of how many times the surrounding SAML message is resubmitted. Always on; no setting.

### Protocol immutability per IdP
Once an Identity Provider is saved, its protocol (SAML or OpenID Connect) is locked for the life of that IdP record — enforced in `IdP_Config::save()` itself, the single choke point every caller (the admin form, and configuration import) funnels through, so no caller can bypass it by taking a different path in. This is not merely a UI restriction: even an imported configuration JSON that names a different protocol for an existing IdP slug is overridden back to the stored value before saving. The reason is identity, not just data hygiene — SAML keys a returning user on `(NameID, idp_slug)` and OpenID Connect keys the same lookup on `(subject claim, idp_slug)`; letting one IdP slug silently migrate from one protocol to the other would let a user who previously authenticated under one identity claim become matchable under a completely different one, without any account-linking check ever running. To change an IdP's protocol, delete it and add it again — a new IdP slug, a new identity binding, no ambiguity in between.

### At-rest encryption of SP private keys
The SP signing key (`sp_private_key`) and the optional separate SP encryption key (`sp_encryption_private_key`) are AES-encrypted inside the `oasso_settings` option, keyed to your WordPress `AUTH_KEY` salt. The IdP's public certificate is intentionally not encrypted. If your WordPress salts are rotated, decryption fails closed: the affected key field surfaces as empty and an admin notice is shown, rather than leaking or mis-using a key.

### Admin XSS and CSV-injection hardening
Inline JSON emitted into admin pages is escaped to prevent stored cross-site scripting from attacker-controlled SAML values. Audit-log CSV exports neutralize spreadsheet formula-injection (cells beginning with `=`, `+`, `-`, `@`, etc.) so an exported log cannot execute formulas when opened in a spreadsheet program.

### Bypass-key brute-force protection
Failed emergency bypass-key attempts are rate-limited per IP address (20 attempts per 15-minute window). The plugin also refuses to save a bypass key shorter than 24 characters.

---

## 2. Security-relevant settings

The settings below are the ones that affect your security posture. Settings exposed in the admin UI are noted with their tab; a small number of escape-hatches are **import-only** (see section 2.7).

### 2.1 `require_audience` — default ON
**Service Provider tab.** When on, an assertion without an `AudienceRestriction` naming this SP, or with the wrong audience, is rejected.

**When to disable:** only if you have a non-conforming IdP that genuinely cannot emit an `AudienceRestriction` for this SP, and you accept the increased replay surface. Re-enable it as soon as the IdP is fixed. Leaving it on is strongly recommended.

### 2.2 `require_assertion_expiry` — default ON
**Service Provider tab.** When on, an assertion that carries no enforceable expiry is rejected. Keep this on. Disable only for a non-conforming IdP that omits expiry, and treat that as a temporary measure.

### 2.3 `allow_sha1_fallback` — default OFF (import-only)
Re-enables acceptance of **RSA-SHA1** SAML signatures (both bindings).

**Risk:** SHA-1 is cryptographically weak and deprecated for signatures. Enabling this widens the attack surface against signature forgery. Leave it off. If a legacy IdP only signs with SHA-1, the correct fix is to reconfigure the IdP to SHA-256, not to relax the SP. When this flag is honored, the plugin writes a warning to the debug log.

### 2.4 `allow_legacy_decryption_algorithms` — default OFF (import-only)
Re-enables **RSA-1.5** key transport and **3DES** data encryption during assertion/NameID decryption.

**Risk:** RSA-1.5 (PKCS#1 v1.5) key transport is vulnerable to padding-oracle (Bleichenbacher-style) attacks, and 3DES is obsolete. Leave it off; require your IdP to use RSA-OAEP + AES-GCM/CBC. When this flag is honored, the plugin writes a warning to the debug log.

### 2.5 `allow_privileged_roles` — default OFF
**Dashboard tab → General Settings.** When off, SSO role mapping will **not** assign any role that carries admin-level capabilities (such as `manage_options` or `edit_users`); a user who would otherwise map to such a role receives the default role instead.

**Why keep it off:** this prevents a compromised, misconfigured, or hostile IdP from minting WordPress administrators by asserting the right group/attribute value. Administrator access should be granted deliberately inside WordPress, not driven by IdP attributes. Only enable this if you fully trust the IdP to govern admin membership and you understand that an IdP compromise then becomes a full WordPress-admin compromise.

### 2.6 Certificate pinning and rotation strategies
**Identity Providers → (edit an IdP).**

- **Certificate pinning** (`cert_pinned`, "Pin this certificate"): when enabled, signature verification fails if the IdP certificate's fingerprint (SHA-256) changes from the pinned value. This defends against an attacker substituting a different signing certificate.
- **Rotation strategy** (`cert_rotation_strategy`) governs what the optional daily/weekly rotation cron does when it detects a new certificate at the IdP metadata URL:
  - `manual` (default) — detect only; you replace the certificate by hand.
  - `require_approval` — the new certificate is queued as "pending"; an admin-wide warning notice appears until you approve or reject it. Nothing is trusted automatically.
  - `grace_period` — the new certificate is added alongside the current one and **both** are trusted for a configurable window (`cert_grace_days`, 1–90, default 7), giving the IdP time to complete its rollover.
  - `auto_trust` — the new certificate is adopted automatically with no approval step.

**Important caveat:** `auto_trust` **self-updates the stored certificate**, including for a pinned IdP. That means a pinned IdP combined with `auto_trust` will silently replace the pinned fingerprint with whatever the metadata URL currently serves — which undermines the point of pinning. For any IdP where you have enabled pinning, use **`require_approval`** (or `grace_period` if you need automatic overlap), not `auto_trust`. Reserve `auto_trust` for IdPs you do not pin and whose metadata URL you fully trust over TLS.

### 2.7 Account linking: `link_existing_by_email` and `allow_email_overwrite`
**Dashboard tab → General Settings.** Both default OFF, and both are conservative on purpose.

- `link_existing_by_email` — when on, a first-time SSO user is matched to an existing WordPress account by email address. **Account-takeover consideration:** a compromised or untrusted IdP could assert a victim's email and claim their account. The plugin already refuses to link by email when the target account is already bound to a different SSO identity, and it **never** links to an account holding admin capability (`manage_options`); these are defense-in-depth guards. Even so, only enable email linking if you trust the IdP to assert email addresses honestly. Leave it off if SSO users should always get fresh accounts.
- `allow_email_overwrite` — when on, later SSO logins may update the WordPress `user_email` from the IdP. When off (default), the email is locked at account creation. **Account-takeover consideration:** enabling overwrite lets the IdP change a user's email after the fact, which can affect password-reset routing and email-based recovery for that account. Keep it off unless you specifically need the IdP to be authoritative for email on every login.

### 2.8 Force SSO and emergency bypass
**Dashboard tab → General Settings** (`force_sso`) and **Access Control tab** (`require_login_all_pages`). These redirect unauthenticated users to the IdP. They are powerful — a misconfigured IdP can lock everyone (including you) out of the login form — so always configure the bypass key (`bypass_secret_key`) before turning them on, and keep the `OASSO_BYPASS` constant in mind for emergencies. See section 4.

### 2.9 Assertion freshness and lifetime — `assertion_max_age` (default 600) and `assertion_max_lifetime` (default 86400)
**Service Provider tab.** An assertion is rejected if it is already older than `assertion_max_age` seconds when it arrives (measured from the IdP's `IssueInstant`, plus your configured clock-skew tolerance), and/or if its declared validity window (`IssueInstant` to `NotOnOrAfter`) exceeds `assertion_max_lifetime` seconds. Both bound a residual replay window that expiry checks alone do not close: without a freshness check, an assertion captured in transit but never delivered stays usable for its entire declared lifetime; without a lifetime cap, a misconfigured or hostile IdP could mint an assertion valid for years.

Either setting accepts `0` to disable. Both can be overridden per IdP with the `oasso_assertion_max_age` and `oasso_assertion_max_lifetime` filters (each receiving `$seconds, $idp_slug, $idp_config`), so one unusually slow or unusually long-lived IdP does not force a loose bound on every other IdP configured on the site. The debug log records each assertion's observed age and declared lifetime, which is what makes an unfamiliar IdP's timing diagnosable before you tighten these from their defaults.

**Recommendation:** keep both on. Widen `assertion_max_age` only if a specific IdP's or network path's delivery latency genuinely exceeds 600 seconds (rare); widen `assertion_max_lifetime` only for an IdP with a documented reason to issue long-lived assertions.

### 2.10 Encrypted-assertion decryption-oracle controls (`saml_failure_min_ms`, `require_signed_response_for_encrypted`)
**Service Provider tab.** Two settings harden the encrypted-assertion path beyond the always-on single-use wrapped-key check (section 1):

- `saml_failure_min_ms` (default `0`, clamped 0–5000) — pads every failed ACS request out to a minimum duration in milliseconds, so a fast rejection (malformed input) and a slow one (a successful decrypt followed by a failed signature check) take indistinguishable time. Without this, the decryption path's own latency is a timing side-channel, even with the wrapped key limited to one use. Set this only if your measured failure-path timing spread (fast vs. slow ACS rejection) is large enough to be practically distinguishable over your network — the shipped default is 0 because both paths measured well under 100 ms in testing.
- `require_signed_response_for_encrypted` (default off) — when on, an encrypted assertion is not decrypted until the surrounding `Response` element's own signature has been verified, removing the decrypt-before-verify ordering entirely for IdPs that sign the whole Response (not just the assertion). Overridable per IdP with `oasso_require_signed_response_for_encrypted` (`$required, $idp_slug, $idp_config`). Leave off if your IdP only signs the assertion — turning it on for such an IdP breaks every encrypted login, since no signed Response element will ever be present to check.

**Recommendation:** enable `require_signed_response_for_encrypted` if your IdP signs the whole Response (check its metadata/behavior first — enabling it for an IdP that doesn't will lock out every encrypted login). Leave `saml_failure_min_ms` at its default unless you have measured a meaningful timing gap on your own infrastructure.

---

## 3. Recommended hardened configuration

Use this checklist for a high-assurance deployment:

- [ ] **Signatures:** leave `allow_sha1_fallback` **off**. Require the IdP to sign with RSA-SHA256 or stronger.
- [ ] **Encryption (if used):** leave `allow_legacy_decryption_algorithms` **off**. Require RSA-OAEP key transport + AES-GCM/CBC.
- [ ] **Audience:** keep `require_audience` **on**.
- [ ] **Expiry:** keep `require_assertion_expiry` **on**.
- [ ] **Assertion freshness/lifetime (SAML):** keep `assertion_max_age` (default 600s) and `assertion_max_lifetime` (default 86400s) **on** (non-zero); tighten per IdP with `oasso_assertion_max_age`/`oasso_assertion_max_lifetime` only if a specific IdP needs it.
- [ ] **Encrypted-assertion signing (SAML, if your IdP supports it):** enable `require_signed_response_for_encrypted` if your IdP signs the whole Response (verify first — enabling it against an IdP that only signs the assertion locks out every encrypted login).
- [ ] **OIDC transport:** never set `oasso_oidc_allow_insecure_transport` to `true` on a site handling real logins — it exists solely for a local/containerized test OP.
- [ ] **Privileged roles:** keep `allow_privileged_roles` **off**; grant WordPress admin manually.
- [ ] **Role mapping:** set a conservative **default role** (for example, Subscriber), and enable **"Deny login if no rule matches"** for IdPs where only mapped users should ever be admitted.
- [ ] **Certificate pinning:** **pin** the IdP certificate, and pair pinning with the **`require_approval`** rotation strategy (never `auto_trust` on a pinned IdP). Configure an automatic check (daily/weekly) so you are notified of rotations.
- [ ] **Account linking:** leave `link_existing_by_email` **off** unless you trust the IdP's email assertions; leave `allow_email_overwrite` **off** unless the IdP must own email.
- [ ] **Clock skew:** keep tolerance tight (the default 120 seconds is reasonable; lower it only if your clocks are well synchronized).
- [ ] **Bypass key:** set a strong `bypass_secret_key` of at least 24 characters (the plugin enforces this minimum) before enabling Force SSO or "Require Login for All Pages".
- [ ] **Transport:** serve the whole site over HTTPS so the bypass cookie is set with the secure flag and SAML messages are not exposed in transit.
- [ ] **Audit log:** enable the **Audit Log** module and set a retention period that meets your needs; review the log periodically and export it (CSV export is formula-injection-safe).
- [ ] **WordPress salts:** remember that rotating `AUTH_KEY`/salts will make the encrypted SP keys undecryptable (fail-closed). If you rotate salts, re-enter the SP private key(s) afterward.

---

## 4. Emergency access / lockout recovery

If Force SSO or "Require Login for All Pages" is on and your IdP becomes unreachable or misconfigured, use one of these to regain access without the IdP:

1. **`OASSO_BYPASS` constant (most reliable).** Add this line to `wp-config.php`:
   ```php
   define( 'OASSO_BYPASS', true );
   ```
   While defined and truthy, forced SSO and the plugin's access gates are bypassed, so you can reach `wp-login.php` and `wp-admin` with a normal WordPress username/password. Remove the line once you have fixed the configuration.

2. **Bypass key via URL.** If you set a **Bypass Secret Key** (`bypass_secret_key`, minimum 24 characters) under Dashboard → General Settings, append it to any URL:
   ```
   https://example.com/wp-login.php?oasso_bypass_key=YOUR-SECRET-KEY
   ```
   A correct key sets a short-lived, HMAC-signed `oasso_bypass` cookie (HMAC-keyed to your `AUTH_KEY` salt; secure flag on HTTPS) that carries the bypass for the rest of the session. Failed attempts are rate-limited per IP (20 per 15 minutes), so brute-forcing the key is impractical — but choose a long, random key regardless.

**Recommendation:** configure the bypass key **before** you enable Force SSO, and keep the `wp-config.php` approach documented for your operations team as the guaranteed fallback (it works even if the database-stored key is unavailable).

---

## 5. OpenID Connect (2.8.0+)

This section applies once at least one Identity Provider is configured with protocol OpenID Connect. The OIDC engine self-gates: on a site with only SAML IdPs configured, none of this code registers and none of these outbound calls happen. Everything below is enforced automatically; there is no module toggle to turn OIDC hardening off short of the two filters named at the end.

### Asymmetric-only signature algorithm allowlist
ID tokens are verified only with **RS256/RS384/RS512**, **PS256/PS384/PS512**, or **ES256/ES384/ES512**. There is no HMAC (`HS*`) code path and no `none`-algorithm code path anywhere in the verifier — not a check that rejects them at runtime, but code that structurally cannot reach them. This closes the classic JWT "algorithm confusion" attack, where a server expecting an asymmetric algorithm is tricked into treating its own public signing key as an HMAC secret. Each `ES*` algorithm is additionally checked for an exact signature length and the matching elliptic curve (RFC 7518 §3.4), not merely an even-length byte string, so a same-field-size curve cannot be substituted for the one the algorithm name implies.

### Login-transaction TTL and browser binding
Each login attempt's state (nonce, PKCE code verifier, target IdP, relay state) is held server-side in a single-use transient keyed by a hash of the OAuth `state` parameter, for **600 seconds** (`OIDC_RP::TRANSACTION_TTL`). PKCE uses the `S256` challenge method only — `plain` is never offered, since it gives none of the protection PKCE exists for. The authorization request also carries a `nonce`, checked against the ID token's `nonce` claim on return, so a captured or predicted `code` cannot be replayed against a different transaction.

The transient alone does not stop login CSRF, since an attacker's own `state` value is genuinely valid for an attacker-initiated flow — so the transaction is additionally **bound to the browser** that started it: `start_login()` sets an httpOnly, `SameSite=Lax`, TLS-`Secure`-when-available cookie (`oasso_oidc_state`) whose value is never stored server-side in the clear, only its SHA-256 hash alongside the transaction. The callback must present a cookie whose hash matches before the transaction is considered valid. The callback uses `response_mode=query` only; `form_post` is never requested, because a cross-site POST is exactly what `SameSite=Lax` does not protect (the plugin already relit this lesson once, from the SAML test-connection cookie bug — see `docs/superpowers/` history) and a top-level GET redirect is exactly what `Lax` does protect.

### JWKS refresh rate limit
A token's `kid` (key ID) header is attacker-chosen on an unauthenticated public endpoint, so refreshing the provider's JWKS on every cache miss would let an attacker drive unbounded outbound HTTP traffic at a timing of their choosing. The plugin refreshes the cached key set when a `kid` isn't found or the cache has aged past `jwks_cache_ttl` (default 43,200 seconds / 12 hours) — but never more than once per **300 seconds** per IdP (`OIDC_Discovery::MIN_REFRESH_INTERVAL`), regardless of how many distinct unmatched `kid` values arrive in that window. A login that hits an unmatched key during the cooldown is rejected rather than triggering another fetch.

### Fail-closed UserInfo
When an IdP is configured to fetch UserInfo (`fetch_userinfo`, needed because Entra ID omits `groups` from the ID token past roughly 150 memberships, and Okta commonly scopes `groups` to UserInfo only), a fetch that is attempted and then fails — transport error, non-200 response, unparsable body — **fails the login**, rather than continuing on the ID token's claims alone. Continuing would silently drop `groups` and any other UserInfo-only claim and could silently downgrade a user to the default role because of a timed-out HTTP call — a privilege change disguised as ordinary operation. A provider with **no UserInfo endpoint configured at all** is treated as a configuration state, not a failure, and is skipped without blocking login. When UserInfo does return successfully, its `sub` claim is compared (via `hash_equals()`) against the ID token's `sub`; a mismatch blocks the login, per OIDC Core §5.3.2, so a substituted UserInfo response cannot attach a different identity's profile to the session that was just authenticated.

### Filters
- **`oasso_oidc_allow_insecure_transport`** (bool, default `false`) — receives `$allow, $url, $context`. Every OIDC runtime endpoint (authorization, token, JWKS, UserInfo, `end_session`) and the discovery document itself are required to be HTTPS; this filter is the only way to opt out, and exists for a containerized test OP on a private network. **Never enable this on a site handling real logins** — the authorization code and the client secret both cross this connection. All of these calls (unlike the SAML metadata fetch, which is an admin-initiated, wp-admin-only action) use `wp_safe_remote_*`, which additionally rejects private and loopback IP ranges via `reject_unsafe_urls` regardless of this filter.
- **`oasso_oidc_max_token_age`** (int seconds, default `600`) — receives `$max_age, $idp_slug, $idp_config`. Bounds how stale the ID token's `iat` (issuance time) may be by the time it's validated on this site, independent of `exp` and of the transaction TTL above. (It does not bound `auth_time` — this plugin never requests or checks that claim.) Narrow this per IdP with the filter if a provider's login ceremony (e.g., an MFA step-up flow) can legitimately take longer than the default, or if you want a tighter bound for a specific high-sensitivity IdP.

---

## Protected media relocation (2.4.0+)

When a page with attached media is restricted, the plugin moves each
attachment's files (original, every resized variant, and image-editor backups)
into `uploads/oasso-private/<random-128-bit-directory>/`. WordPress's own
records keep the file's ORIGINAL path — the private location is resolved only
while the plugin is active. This is deliberate: if the plugin is off, pages
emit the old (now dead) URL rather than the secret one, so a deactivation can
never expose the private path. On Apache the private directory is additionally
denied by `.htaccess`; on servers that ignore `.htaccess` (nginx), the
unguessable directory name plus the gateway is the control — and note that
files placed MANUALLY in `uploads/oasso-protected/` are NOT shielded from
direct URL access on such servers (use the Media Library + page restriction
instead). Every relocation, restore, and failure is recorded in the audit log.

---

## Reference

- Plugin slug / text domain: `open-access-sso` · Option prefix: `oasso_` · Constant prefix: `OASSO_`
- License: GPLv2 or later
- Source: <https://codeberg.org/idgold/open-access-sso>
- Bundled runtime dependencies (both MIT): `robrichards/xmlseclibs`, `phpseclib/phpseclib`
