Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
cf389a2
feat(security): add secure (opaque-origin) content iframe mode
erseco Jun 13, 2026
9eb07ff
i18n: translate the secure-iframe admin strings; trim the settings copy
erseco Jun 13, 2026
1452468
style(admin): inline the mode write to satisfy phpcs alignment sniff
erseco Jun 13, 2026
7adca0c
fix(security): sandbox the content response in secure mode (raw-route…
erseco Jun 13, 2026
4416ccd
Render external embeds (YouTube/Vimeo/PDF) inline in secure mode
erseco Jun 14, 2026
4266410
Report absolute embed URLs from the shim (resolve relative srcs)
erseco Jun 14, 2026
76f40fe
Add Firefox e2e for secure-mode external embeds
erseco Jun 14, 2026
15f1b71
Keep the embed Firefox e2e out of the WordPress (chromium/wp-env) Pla…
erseco Jun 14, 2026
8fc1354
Mirror canonical embed updates: providers, clamp, allow-forms, page-n…
erseco Jun 14, 2026
53fd157
Fix PHPCS: capitalize the allow-forms docblock long description
erseco Jun 14, 2026
f162e72
Mirror canonical embeds: structural invariant (any cross-origin https…
erseco Jun 14, 2026
d04ed58
Update embed e2e for open mode: every cross-origin/PDF iframe promote…
erseco Jun 14, 2026
a9d39a3
Revert embed-policy admin UI (untranslated-strings i18n gate); keep o…
erseco Jun 14, 2026
b41dc7b
Mirror canonical embed hardening: trailing-dot gate, dead-code, Vitest
erseco Jun 17, 2026
f0a193b
Merge remote-tracking branch 'origin/main' into feature/secure-iframe…
erseco Jun 18, 2026
1db0efc
Merge branch 'main' into feature/secure-iframe-sandbox
erseco Jun 26, 2026
0ce4313
Merge remote-tracking branch 'origin/main' into feature/secure-iframe…
erseco Jun 29, 2026
0a3ee17
feat(secure-iframe): id-only embed channel + parent media host (DEC-0…
erseco Jun 29, 2026
373a058
fix(secure-iframe): sandbox cross-origin PDF players + relay teardown…
erseco Jun 29, 2026
0dfd1bb
feat(secure-iframe): always opaque + strict CSP/embed defaults; dev-o…
erseco Jun 29, 2026
038c860
fix(playground): reliably force legacy in the Playground; drop the al…
erseco Jun 30, 2026
46f6959
fix(i18n): move the dev-only escape-hatch warning to the Playground m…
erseco Jun 30, 2026
f5b8e56
test(iframe): assert the removed legacy option stays opaque; trim Pla…
erseco Jun 30, 2026
6ce3599
docs(embed): repoint embedder header to eXe core as the canonical source
erseco Jul 2, 2026
5064009
feat(preview): host-served opaque HTTP preview serving contract (refe…
erseco Jul 6, 2026
f45db76
fix(preview): satisfy WordPress coding standards (WPCS)
erseco Jul 6, 2026
2dedda8
Merge remote-tracking branch 'origin/main' into feature/secure-iframe…
erseco Jul 6, 2026
552cb69
fix(preview): use plain // comments for the route list (WPCS)
erseco Jul 6, 2026
e8e9533
Merge branch 'main' into feature/secure-iframe-sandbox
erseco Jul 6, 2026
a444f07
Merge branch 'main' of github.com:exelearning/wp-exelearning into fea…
erseco Jul 9, 2026
68b60ae
Merge branch 'main' of github.com:exelearning/wp-exelearning into fea…
erseco Jul 10, 2026
afcd0b9
feat(preview): file-backed session store + fixed-resource resolver (c…
erseco Jul 11, 2026
ebfe892
feat(preview): rework preview proxy to serving contract v2 and wire i…
erseco Jul 11, 2026
86c5b75
feat(preview): reclaim idle preview sessions via WP-Cron
erseco Jul 11, 2026
e97dc70
docs(preview): rewrite host serving-contract mirror to v2
erseco Jul 11, 2026
035a370
test(preview): unit tests + conformance vector replay for serving con…
erseco Jul 11, 2026
bfe8fa7
fix(embed): mirror core shim geometry re-report + relay drift check
erseco Jul 11, 2026
0098efd
fix(preview): deny direct web access to the preview session store
erseco Jul 11, 2026
75647bb
fix(preview): reject a revision/assets batch with a failed upload part
erseco Jul 11, 2026
90474df
fix(embed): mirror relay dispose() + idempotent init (drift-timer tea…
erseco Jul 11, 2026
083029d
i18n(preview): translate the preview-cleanup cron label
erseco Jul 11, 2026
8fbd029
refactor(preview): reduce PHPMD complexity in preview proxy/store
erseco Jul 11, 2026
ae824bc
Merge remote-tracking branch 'origin/main' into feature/secure-iframe…
erseco Jul 11, 2026
2a67d29
style(preview): satisfy WordPress Coding Standards (phpcs)
erseco Jul 11, 2026
d1b58e1
build(phpcs): anchor the uploads exclude-pattern to close the local f…
erseco Jul 11, 2026
c771805
i18n: regenerate .pot/.po/.mo to match current source
erseco Jul 11, 2026
8d3b0f3
feat(preview): split the HTTP adapter into serving + management contr…
erseco Jul 11, 2026
4399bd5
feat(editor): wire the previewHttp transport and drop the Service Wor…
erseco Jul 11, 2026
334e0cc
docs(preview): normalized two-URL previewHttp model + serving deltas
erseco Jul 11, 2026
b685d82
i18n: regenerate .pot/.po/.mo for the preview permalink admin notice
erseco Jul 11, 2026
e528eaf
docs(preview): note the X-WP-Nonce lifetime for long editing sessions
erseco Jul 11, 2026
09ba0df
fix(preview): canonical Range ordering + relative bare-root redirect;…
erseco Jul 11, 2026
a8eb535
fix(preview): drop trailing semicolon from sandbox CSP for byte-ident…
erseco Jul 11, 2026
c1b287e
fix(editor): neutralize Service Worker registration during the pre-HT…
erseco Jul 11, 2026
11ac94f
fix(preview): ship an nginx deny snippet for the preview session store
erseco Jul 11, 2026
44618a1
docs(preview): serving deltas, CSP byte-identity, nginx guard, SW int…
erseco Jul 11, 2026
643da31
fix(preview): never index or publish a failed store write (contract §5)
erseco Jul 11, 2026
30800a8
fix(preview): detect short writes + cover prior-revision-survives (co…
erseco Jul 11, 2026
040b063
fix(preview): use a private site-scoped session store
erseco Jul 12, 2026
062f759
test(preview): verify private default session storage
erseco Jul 12, 2026
3697029
docs(preview): align contract with private storage
erseco Jul 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public/style/workarea/*.css.map
# Test results
test-results/
test-results/*
playwright-report/

# Built static editor - download from releases or build with `make build-editor`
dist/static/
Expand Down
5 changes: 4 additions & 1 deletion .phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
<exclude-pattern>/tests/*</exclude-pattern>
<exclude-pattern>/includes/vendor/*</exclude-pattern>
<exclude-pattern>/dist/*</exclude-pattern>
<exclude-pattern>/exelearning/*</exclude-pattern>
<!-- Only the WordPress uploads extraction dir, path-anchored. A bare
`/exelearning/*` matched ANY checkout path containing an `exelearning/`
segment, silently excluding the whole plugin from local phpcs runs. -->
<exclude-pattern>*/wp-content/uploads/exelearning/*</exclude-pattern>

<arg name="extensions" value="php"/>
<arg name="colors"/>
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ EXELEARNING_EDITOR_REF=my-feature EXELEARNING_EDITOR_REF_TYPE=branch make build-

> **Important:** It is recommended to download from [Releases](https://github.com/exelearning/wp-exelearning/releases) for production use, which includes the embedded editor pre-built. If you clone the repository without building the editor, you can install it from the WordPress admin panel at **Settings > eXeLearning** using the "Download & Install Editor" button, which fetches the latest static editor package from GitHub Releases automatically. No remote loading or proxy is used at runtime.

### Server configuration (nginx)

The live editor **preview** requires **pretty permalinks** (Settings → Permalinks; any structure other than *Plain*) — under plain permalinks the plugin fails closed and shows an admin notice.

On **nginx**, also block direct web access to the ephemeral preview session store: the plugin serves those bytes through an opaque-origin capability URL with a sandbox CSP, and unlike Apache (`.htaccess`) nginx will otherwise serve the materialized author HTML same-origin without that CSP. Include the shipped snippet from your `server { … }` block:

```nginx
include /path/to/wp-content/plugins/exelearning/nginx-exelearning-preview.conf;
```

See [`nginx-exelearning-preview.conf`](nginx-exelearning-preview.conf) and [`docs/preview-serving-contract.md`](docs/preview-serving-contract.md) for details.

## Usage

### Uploading ELPX Files
Expand Down Expand Up @@ -103,6 +115,30 @@ Administrators can upload eXeLearning style packages and control which styles th

Uploaded ZIPs are validated against path traversal, absolute paths, oversize archives (default 20 MB, filterable via `exelearning_styles_max_zip_size`), and a strict file-extension allow-list.

## External embeds in secure mode

In secure mode the `.elpx` content runs in a sandboxed, opaque-origin iframe. That
opaque origin propagates to any nested iframe, so cross-origin video players and PDF
viewers render blank. To keep them working, whitelisted video embeds (YouTube and
Vimeo hosts), any cross-origin `https` `.pdf`, and the package's own local PDFs are
*promoted* to the trusted parent page and rendered inline on top of the content.

Two cooperating scripts make this work:

- `assets/js/exe-embed-shim.js` runs inside the content iframe, replaces each
promotable iframe with a same-size placeholder, and `postMessage`s its geometry
and URL to the parent.
- `assets/js/exe-embed-relay.js` runs on the host page, validates each reported URL
against the whitelist, rebuilds the canonical player URL, and overlays the real
player exactly over the placeholder.

A static Firefox end-to-end test exercises the real shim and relay against a
self-contained harness (no WordPress runtime needed):

```bash
npm run test:e2e:embed
```

## Developer hooks

The plugin exposes a set of WordPress actions and filters (all prefixed with
Expand Down
121 changes: 32 additions & 89 deletions admin/views/editor-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@
'fallbackTheme' => 'base',
);

// Opaque HTTP preview transport (serving contract v2). Emitted only under
// pretty permalinks; otherwise `previewHttp` is omitted so the editor fails
// closed (no silent same-origin fallback) and ExeLearning_Editor's admin notice
// explains the requirement. wp_json_encode() output is valid JS object syntax.
$exelearning_preview_http = ExeLearning_Editor::build_preview_http_config( $exelearning_nonce );
$exelearning_preview_http_js = null === $exelearning_preview_http
? ''
: "\n previewHttp: " . wp_json_encode( $exelearning_preview_http ) . ',';

// Inject WordPress configuration BEFORE the closing </head> tag.
// phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript -- Standalone HTML page output, not a WordPress template.
$exelearning_wp_config_script = sprintf(
Expand Down Expand Up @@ -200,15 +209,18 @@ enumerable: true,
fileMenu: true,
saveButton: true,
userMenu: true,
},
},%s
};

// TODO: Remove when editor ResourceFetcher handles 404 gracefully.
// Patch fetch and jQuery AJAX to handle CSS/idevices 404s without breaking.
// Embedded-editor shims: hide the chrome the host owns and soften CSS /
// idevice 404s so a missing optional asset never breaks boot. The live
// preview travels over HTTP (see previewHttp above), never a Service
// Worker on the WordPress origin — so there is no preview-sw / /viewer/
// wiring here.
// TODO: Remove the 404 shim when the editor ResourceFetcher handles 404
// gracefully.
(function() {
var editorBaseUrl = (window.__WP_EXE_CONFIG__ && window.__WP_EXE_CONFIG__.editorBaseUrl) || "";
var editorBasePathname = "";
var originalServiceWorker = navigator.serviceWorker || null;
var forceHideSelectors = [
"#dropdownFile",
"#head-top-save-button",
Expand All @@ -218,12 +230,6 @@ enumerable: true,
"#mobile-navbar-button-openuserodefiles"
];

try {
editorBasePathname = editorBaseUrl ? new URL(editorBaseUrl, window.location.origin).pathname : "";
} catch (e) {
editorBasePathname = "";
}

function forceHideEmbeddedUi() {
for (var i = 0; i < forceHideSelectors.length; i += 1) {
var nodes = document.querySelectorAll(forceHideSelectors[i]);
Expand All @@ -243,94 +249,20 @@ function forceHideEmbeddedUi() {
}
}

function normalizePreviewIframeSrc(url) {
if (!url || !editorBaseUrl) {
return url;
}

var baseNoSlash = editorBaseUrl.replace(/\/$/, "");
var raw = url;

try {
if (raw.startsWith("http://") || raw.startsWith("https://")) {
raw = new URL(raw).pathname;
}
} catch (e) {}

if (raw.indexOf("/wp-admin/admin.php/viewer/") === 0) {
return baseNoSlash + "/viewer/" + raw.substring("/wp-admin/admin.php/viewer/".length);
}
if (raw.indexOf("/viewer/") === 0) {
return baseNoSlash + raw;
}
if (raw.indexOf("viewer/") === 0) {
return baseNoSlash + "/" + raw;
}

return url;
}

function ensurePreviewIframeSrc() {
var previewIframe = document.getElementById("preview-iframe");
if (!previewIframe) {
return;
}

var currentSrc = previewIframe.getAttribute("src") || previewIframe.src || "";
var fixedSrc = normalizePreviewIframeSrc(currentSrc);
if (fixedSrc && fixedSrc !== currentSrc) {
previewIframe.setAttribute("src", fixedSrc);
}
}

if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", forceHideEmbeddedUi);
document.addEventListener("DOMContentLoaded", ensurePreviewIframeSrc);
} else {
forceHideEmbeddedUi();
ensurePreviewIframeSrc();
}

var hideObserver = new MutationObserver(function() {
forceHideEmbeddedUi();
ensurePreviewIframeSrc();
});
hideObserver.observe(document.documentElement || document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["src"]
subtree: true
});

// Fix preview service worker paths in WP mode.
if (originalServiceWorker && editorBasePathname) {
var registerOriginal = originalServiceWorker.register.bind(originalServiceWorker);
var getRegistrationOriginal = originalServiceWorker.getRegistration.bind(originalServiceWorker);
var fixedSwPath = editorBasePathname.replace(/\/$/, "") + "/preview-sw.js";
var fixedScope = editorBasePathname.replace(/\/$/, "") + "/viewer/";

originalServiceWorker.register = function(scriptURL, options) {
var nextScript = scriptURL;
var nextOptions = options || {};
if (typeof nextScript === "string" && nextScript.indexOf("preview-sw.js") !== -1) {
nextScript = fixedSwPath;
nextOptions = Object.assign({}, nextOptions, { scope: fixedScope });
}
return registerOriginal(nextScript, nextOptions);
};

originalServiceWorker.getRegistration = function(clientURL) {
var nextClientUrl = clientURL;
if (
!nextClientUrl ||
(typeof nextClientUrl === "string" && nextClientUrl.indexOf("/wp-admin/") === 0)
) {
nextClientUrl = fixedScope;
}
return getRegistrationOriginal(nextClientUrl);
};
}

function normalizeEditorAssetUrl(url) {
if (!url || typeof url !== "string" || !editorBaseUrl) {
return url;
Expand Down Expand Up @@ -482,6 +414,7 @@ function normalizeEditorAssetUrl(url) {
wp_json_encode( $exelearning_editor_base_url ),
wp_json_encode( $exelearning_i18n ),
wp_json_encode( $exelearning_theme_registry_override ),
$exelearning_preview_http_js,
esc_url( $exelearning_plugin_assets_url )
);
// phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript
Expand Down Expand Up @@ -538,10 +471,20 @@ function normalizeEditorAssetUrl(url) {
// Insert config script and styles before </head>.
$exelearning_template = str_replace( '</head>', $exelearning_wp_config_script . $exelearning_page_styles . '</head>', $exelearning_template );

// Add <base> tag to set the base URL for all relative paths.
// This ensures paths like "files/perm/..." resolve to the static editor directory.
// Add <base> tag to set the base URL for all relative paths, and a first-thing
// Service Worker guard so the bundled pre-HTTP-v2 editor can never register a
// same-origin /viewer/ preview worker on the WordPress origin (interim
// protection; see ExeLearning_Editor::service_worker_guard_script). Both are
// injected right after <head>, before any editor script runs.
// phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript -- Standalone HTML page output, not a WordPress template.
$exelearning_sw_guard_tag = '<script>' . ExeLearning_Editor::service_worker_guard_script() . '</script>';
// phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript
$exelearning_base_tag = sprintf( '<base href="%s/">', esc_url( $exelearning_editor_base_url ) );
$exelearning_template = preg_replace( '/(<head[^>]*>)/i', '$1' . $exelearning_base_tag, $exelearning_template );
$exelearning_template = preg_replace(
'/(<head[^>]*>)/i',
'$1' . $exelearning_sw_guard_tag . $exelearning_base_tag,
$exelearning_template
);

// Fix asset paths: Replace relative paths with absolute plugin paths.
// The static build uses relative paths like "./app/", we need absolute paths.
Expand Down
Loading