@pie-players/pie-players-shared 0.3.69 → 0.3.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/loaders/iife-adapter.js +7 -3
- package/dist/pie/element-observer.d.ts +59 -0
- package/dist/pie/element-observer.js +131 -0
- package/dist/pie/index.d.ts +4 -2
- package/dist/pie/index.js +7 -2
- package/dist/pie/initialization.d.ts +13 -13
- package/dist/pie/initialization.js +97 -147
- package/dist/pie/initialize-element.d.ts +23 -0
- package/dist/pie/initialize-element.js +78 -0
- package/dist/pie/instrumentation-event-map.d.ts +1 -0
- package/dist/pie/instrumentation-event-map.js +18 -0
- package/dist/pie/math-rendering.js +6 -2
- package/dist/pie/types.d.ts +10 -0
- package/dist/pie/utils.d.ts +27 -0
- package/dist/pie/utils.js +56 -1
- package/dist/security/index.d.ts +3 -2
- package/dist/security/index.js +3 -2
- package/dist/security/sanitize-forbidden-lists.js +9 -0
- package/dist/security/sanitize-item-markup.js +4 -0
- package/dist/security/sanitize-style-attribute.d.ts +48 -0
- package/dist/security/sanitize-style-attribute.js +129 -0
- package/dist/security/sanitize-svg-icon.js +2 -0
- package/dist/security/validate-style-url.d.ts +13 -0
- package/dist/security/validate-style-url.js +36 -2
- package/dist/security/wrap-overwide-images.d.ts +7 -0
- package/dist/security/wrap-overwide-images.js +10 -1
- package/dist/security/wrap-overwide-tables.d.ts +7 -0
- package/dist/security/wrap-overwide-tables.js +10 -1
- package/dist/security/wrap-overwide.d.ts +16 -0
- package/dist/security/wrap-overwide.js +52 -0
- package/dist/ui/overlay-containment.d.ts +48 -0
- package/dist/ui/overlay-containment.js +65 -0
- package/package.json +6 -6
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binding a single PIE element to its model and session.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `initialization.ts` so the late-arrival observer in
|
|
5
|
+
* `element-observer.ts` can reuse it without importing the bundle loaders.
|
|
6
|
+
*/
|
|
7
|
+
import type { ConfigEntity, Env } from "../types/index.js";
|
|
8
|
+
import type { EventListeners, PieElement } from "./types.js";
|
|
9
|
+
/**
|
|
10
|
+
* Bind `element` to the model in `options.config` whose `id` matches it.
|
|
11
|
+
*
|
|
12
|
+
* Returns whether the element is bound: `true` once a model has been applied
|
|
13
|
+
* (including on a repeat call for an element that is already bound), `false`
|
|
14
|
+
* when the config carries no model for this `id`. A caller holding several
|
|
15
|
+
* configs — an item player registers its item config and its passage config
|
|
16
|
+
* separately — uses that to stop at the config the element belongs to.
|
|
17
|
+
*/
|
|
18
|
+
export declare const initializePieElement: (element: PieElement, options: {
|
|
19
|
+
config: ConfigEntity;
|
|
20
|
+
session: any[];
|
|
21
|
+
env?: Env;
|
|
22
|
+
eventListeners?: EventListeners;
|
|
23
|
+
}) => boolean;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binding a single PIE element to its model and session.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `initialization.ts` so the late-arrival observer in
|
|
5
|
+
* `element-observer.ts` can reuse it without importing the bundle loaders.
|
|
6
|
+
*/
|
|
7
|
+
import { wrapModelRichContent } from "../security/wrap-model-rich-content.js";
|
|
8
|
+
import { createPieLogger, isGlobalDebugEnabled } from "./logger.js";
|
|
9
|
+
import { pieRegistry } from "./registry.js";
|
|
10
|
+
import { findPieController } from "./scoring.js";
|
|
11
|
+
import { BundleType } from "./types.js";
|
|
12
|
+
import { findOrAddSession } from "./utils.js";
|
|
13
|
+
const logger = createPieLogger("pie-initialize-element", () => isGlobalDebugEnabled());
|
|
14
|
+
/**
|
|
15
|
+
* Bind `element` to the model in `options.config` whose `id` matches it.
|
|
16
|
+
*
|
|
17
|
+
* Returns whether the element is bound: `true` once a model has been applied
|
|
18
|
+
* (including on a repeat call for an element that is already bound), `false`
|
|
19
|
+
* when the config carries no model for this `id`. A caller holding several
|
|
20
|
+
* configs — an item player registers its item config and its passage config
|
|
21
|
+
* separately — uses that to stop at the config the element belongs to.
|
|
22
|
+
*/
|
|
23
|
+
export const initializePieElement = (element, options) => {
|
|
24
|
+
const { config, session, env, eventListeners } = options;
|
|
25
|
+
if (element.__pieInitialized) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
const tagName = element.tagName.toLowerCase();
|
|
29
|
+
logger.debug(`[initializePieElement] Initializing ${tagName}#${element.id}`);
|
|
30
|
+
// Find model for this element
|
|
31
|
+
const model = config?.models?.find((m) => m.id === element.id);
|
|
32
|
+
if (!model) {
|
|
33
|
+
// Only warn if this element is from a client-player.js bundle (where models are expected)
|
|
34
|
+
// player.js bundles use server-processed models, so missing models are expected there
|
|
35
|
+
const registry = pieRegistry();
|
|
36
|
+
const registryEntry = registry[tagName];
|
|
37
|
+
if (registryEntry && registryEntry.bundleType === BundleType.clientPlayer) {
|
|
38
|
+
logger.warn(`[initializePieElement] Model not found for PIE element ${tagName}#${element.id} (client-player.js bundle)`);
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
// Set session (with element property for updateSession callback)
|
|
43
|
+
const elementSession = findOrAddSession(session, model.id, model.element);
|
|
44
|
+
element.session = elementSession;
|
|
45
|
+
element.__pieInitialized = true;
|
|
46
|
+
logger.debug(`[initializePieElement] Session set for ${tagName}#${element.id}:`, elementSession);
|
|
47
|
+
// Set model - use controller if available (client-player.js), or use server-processed model (player.js)
|
|
48
|
+
const controller = findPieController(tagName);
|
|
49
|
+
if (!env) {
|
|
50
|
+
logger.error(`[initializePieElement] ❌ FATAL: No env provided for ${tagName}`);
|
|
51
|
+
throw new Error(`No env provided for ${tagName}. PIE elements require an env object with mode and role.`);
|
|
52
|
+
}
|
|
53
|
+
if (!controller) {
|
|
54
|
+
// No controller available - using server-processed model (player.js bundle)
|
|
55
|
+
logger.debug(`[initializePieElement] ℹ️ No controller for ${tagName}, using server-processed model`);
|
|
56
|
+
logger.debug(`[initializePieElement] Model already processed by server:`, {
|
|
57
|
+
id: model.id,
|
|
58
|
+
element: model.element,
|
|
59
|
+
hasCorrectResponse: "correctResponse" in model,
|
|
60
|
+
mode: env.mode,
|
|
61
|
+
role: env.role,
|
|
62
|
+
});
|
|
63
|
+
// Set model directly - server already processed it
|
|
64
|
+
element.model = wrapModelRichContent(model);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
// Controller available - run client-side processing (client-player.js bundle)
|
|
68
|
+
// Note: updatePieElementWithRef handles controller invocation
|
|
69
|
+
logger.debug(`[initializePieElement] Controller found for ${tagName}, will invoke model() function`);
|
|
70
|
+
}
|
|
71
|
+
// Add event listeners
|
|
72
|
+
if (eventListeners) {
|
|
73
|
+
Object.entries(eventListeners).forEach(([evt, fn]) => {
|
|
74
|
+
element.addEventListener(evt, fn);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
};
|
|
@@ -4,4 +4,5 @@ export type InstrumentationEventMapping = {
|
|
|
4
4
|
};
|
|
5
5
|
export declare const TOOLKIT_INSTRUMENTATION_EVENT_MAP: InstrumentationEventMapping[];
|
|
6
6
|
export declare const SECTION_INSTRUMENTATION_EVENT_MAP: InstrumentationEventMapping[];
|
|
7
|
+
export declare const ITEM_INSTRUMENTATION_EVENT_MAP: InstrumentationEventMapping[];
|
|
7
8
|
export declare const ASSESSMENT_INSTRUMENTATION_EVENT_MAP: InstrumentationEventMapping[];
|
|
@@ -67,6 +67,24 @@ export const SECTION_INSTRUMENTATION_EVENT_MAP = [
|
|
|
67
67
|
instrumentationEventName: "pie-section-element-preload-error",
|
|
68
68
|
},
|
|
69
69
|
];
|
|
70
|
+
export const ITEM_INSTRUMENTATION_EVENT_MAP = [
|
|
71
|
+
// Only the security signal is mapped, deliberately. The item player's other
|
|
72
|
+
// public events (`load-complete`, `player-error`, `model-updated`,
|
|
73
|
+
// `model-loaded`, `session-changed` and the `backend-*` family) stay off the
|
|
74
|
+
// bridge because `session-changed` carries the learner's responses, and
|
|
75
|
+
// forwarding response data to a host's telemetry provider by default is the
|
|
76
|
+
// host's decision to make rather than this package's default. A host that
|
|
77
|
+
// wants them can attach its own bridge with its own map.
|
|
78
|
+
//
|
|
79
|
+
// `correct-responses-populated` firing at all is the signal: population
|
|
80
|
+
// requires a controller with `createCorrectResponseSession` in the browser,
|
|
81
|
+
// which only a `client-player.js` bundle provides, and the attributes that
|
|
82
|
+
// request it (`add-correct-response`, `env`, `mode`) are all client-mutable.
|
|
83
|
+
{
|
|
84
|
+
sourceEventName: "correct-responses-populated",
|
|
85
|
+
instrumentationEventName: "pie-item-correct-responses-populated",
|
|
86
|
+
},
|
|
87
|
+
];
|
|
70
88
|
export const ASSESSMENT_INSTRUMENTATION_EVENT_MAP = [
|
|
71
89
|
{
|
|
72
90
|
sourceEventName: "assessment-controller-ready",
|
|
@@ -72,8 +72,12 @@ export async function initializeMathRendering(customRenderer) {
|
|
|
72
72
|
initPromise = (async () => {
|
|
73
73
|
try {
|
|
74
74
|
const { _dll_pie_lib__math_rendering } = await import("@pie-lib/math-rendering-module/module");
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
// A host may install its renderer while the default module is in flight.
|
|
76
|
+
// The explicit renderer remains authoritative when that happens.
|
|
77
|
+
if (!getWindowRenderer()) {
|
|
78
|
+
setWindowRenderer(_dll_pie_lib__math_rendering);
|
|
79
|
+
logger.debug("Math rendering module initialized (both globals set)");
|
|
80
|
+
}
|
|
77
81
|
}
|
|
78
82
|
catch (error) {
|
|
79
83
|
logger.error("Failed to initialize math rendering:", error);
|
package/dist/pie/types.d.ts
CHANGED
|
@@ -123,6 +123,16 @@ export interface LoadPieElementsOptions {
|
|
|
123
123
|
bundleUrl?: string;
|
|
124
124
|
eventListeners?: EventListenersMap;
|
|
125
125
|
container?: Element | Document;
|
|
126
|
+
/**
|
|
127
|
+
* Deadline in ms for `loadPieModule`'s bundle `<script>` load.
|
|
128
|
+
*
|
|
129
|
+
* A stalled request fires neither `load` nor `error`, so without a
|
|
130
|
+
* deadline the returned promise never settles. Named and defaulted to
|
|
131
|
+
* match `EnsureRegisteredOptions.loadTimeoutMs` on the `ElementLoader`
|
|
132
|
+
* primitive, so both bundle-loading paths give a host one budget to
|
|
133
|
+
* reason about. `0` or negative disables the deadline.
|
|
134
|
+
*/
|
|
135
|
+
loadTimeoutMs?: number;
|
|
126
136
|
}
|
|
127
137
|
/**
|
|
128
138
|
* Type guard: Check if object has window.pie
|
package/dist/pie/utils.d.ts
CHANGED
|
@@ -5,6 +5,33 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { ConfigEntity } from "../types/index.js";
|
|
7
7
|
import type { LoadPieElementsOptions } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* Percent-encode one element package spec for a build-service bundle path.
|
|
10
|
+
*
|
|
11
|
+
* `config.elements` is authored content, so a spec must not be able to
|
|
12
|
+
* restructure the URL. Escaping everything outside
|
|
13
|
+
* {@link BUNDLE_PATH_LITERAL_CHARS} is what stops it: `#` no longer truncates
|
|
14
|
+
* the path at a fragment, `?` no longer turns the remainder into a query
|
|
15
|
+
* string (which in `buildBundleUrl` also swallowed the real `?elements=`
|
|
16
|
+
* parameter), `\` is no longer normalized to `/` by the URL parser, `%` can
|
|
17
|
+
* no longer smuggle an escape the build service decodes back into a
|
|
18
|
+
* separator, and an in-spec `+` becomes `%2B` instead of a phantom package
|
|
19
|
+
* boundary.
|
|
20
|
+
*
|
|
21
|
+
* Both blanket encoders are wrong here, so do not "simplify" to either.
|
|
22
|
+
* `encodeURI` — what this replaced — leaves `/`, `?`, `#` and `%` alone.
|
|
23
|
+
* `encodeURIComponent` escapes `/` and `@`, which this route needs literal;
|
|
24
|
+
* it is correct for the `elements=` query value, which is why both encoders
|
|
25
|
+
* appear in `buildBundleUrl`.
|
|
26
|
+
*/
|
|
27
|
+
export declare const encodeElementPackageSpec: (spec: string) => string;
|
|
28
|
+
/**
|
|
29
|
+
* Encode each element package spec and join them with the literal `+` the
|
|
30
|
+
* legacy IIFE bundle route uses as its package separator. Encoding the joined
|
|
31
|
+
* string instead would escape the separator. That same separator is why
|
|
32
|
+
* `element-package-policy` rejects semver build metadata.
|
|
33
|
+
*/
|
|
34
|
+
export declare const encodeElementPackageSpecs: (specs: Iterable<string>) => string;
|
|
8
35
|
/**
|
|
9
36
|
* Build URL for fetching PIE element bundles from build service
|
|
10
37
|
*/
|
package/dist/pie/utils.js
CHANGED
|
@@ -3,12 +3,67 @@
|
|
|
3
3
|
*
|
|
4
4
|
* URL building, package name parsing, and session utilities.
|
|
5
5
|
*/
|
|
6
|
+
/**
|
|
7
|
+
* Characters an element package spec may carry into a bundle path unescaped.
|
|
8
|
+
*
|
|
9
|
+
* `@` and `/` are in the set because the build service route is
|
|
10
|
+
* `<host>/<spec>+<spec>.../<bundleType>`, and a scoped spec
|
|
11
|
+
* (`@pie-element/multiple-choice@9.9.1`) spans path segments inside it —
|
|
12
|
+
* escaping either yields a path the service does not match. The rest is the
|
|
13
|
+
* RFC 3986 unreserved set, which covers every npm package name and every
|
|
14
|
+
* semver version. The `u` flag makes a surrogate pair match as one code
|
|
15
|
+
* point, so an astral character encodes as a whole character.
|
|
16
|
+
*/
|
|
17
|
+
const BUNDLE_PATH_LITERAL_CHARS = /[^A-Za-z0-9\-._~@/]/gu;
|
|
18
|
+
/** Path segments the URL parser resolves away before a request is sent. */
|
|
19
|
+
const DOT_SEGMENTS = new Set([".", ".."]);
|
|
20
|
+
/**
|
|
21
|
+
* Percent-encode one element package spec for a build-service bundle path.
|
|
22
|
+
*
|
|
23
|
+
* `config.elements` is authored content, so a spec must not be able to
|
|
24
|
+
* restructure the URL. Escaping everything outside
|
|
25
|
+
* {@link BUNDLE_PATH_LITERAL_CHARS} is what stops it: `#` no longer truncates
|
|
26
|
+
* the path at a fragment, `?` no longer turns the remainder into a query
|
|
27
|
+
* string (which in `buildBundleUrl` also swallowed the real `?elements=`
|
|
28
|
+
* parameter), `\` is no longer normalized to `/` by the URL parser, `%` can
|
|
29
|
+
* no longer smuggle an escape the build service decodes back into a
|
|
30
|
+
* separator, and an in-spec `+` becomes `%2B` instead of a phantom package
|
|
31
|
+
* boundary.
|
|
32
|
+
*
|
|
33
|
+
* Both blanket encoders are wrong here, so do not "simplify" to either.
|
|
34
|
+
* `encodeURI` — what this replaced — leaves `/`, `?`, `#` and `%` alone.
|
|
35
|
+
* `encodeURIComponent` escapes `/` and `@`, which this route needs literal;
|
|
36
|
+
* it is correct for the `elements=` query value, which is why both encoders
|
|
37
|
+
* appear in `buildBundleUrl`.
|
|
38
|
+
*/
|
|
39
|
+
export const encodeElementPackageSpec = (spec) => {
|
|
40
|
+
const escaped = spec.replace(BUNDLE_PATH_LITERAL_CHARS, (char) => encodeURIComponent(char));
|
|
41
|
+
const segments = escaped.split("/");
|
|
42
|
+
// A literal `/` next to a `.` or `..` segment is the one remaining way
|
|
43
|
+
// authored content rewrites the path: the URL parser resolves dot segments
|
|
44
|
+
// before the request is sent, so `@pie-element/../../x` would leave the
|
|
45
|
+
// bundles route. Percent-encoding the dots does not help — the parser
|
|
46
|
+
// recognizes `%2e` as a dot segment as well. Escaping every `/` in such a
|
|
47
|
+
// spec collapses it into one inert segment that 404s inside the route. No
|
|
48
|
+
// npm package name or subpath is `.` or `..`, so nothing legitimate takes
|
|
49
|
+
// this branch.
|
|
50
|
+
return segments.some((segment) => DOT_SEGMENTS.has(segment))
|
|
51
|
+
? segments.join("%2F")
|
|
52
|
+
: escaped;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Encode each element package spec and join them with the literal `+` the
|
|
56
|
+
* legacy IIFE bundle route uses as its package separator. Encoding the joined
|
|
57
|
+
* string instead would escape the separator. That same separator is why
|
|
58
|
+
* `element-package-policy` rejects semver build metadata.
|
|
59
|
+
*/
|
|
60
|
+
export const encodeElementPackageSpecs = (specs) => Array.from(specs, encodeElementPackageSpec).join("+");
|
|
6
61
|
/**
|
|
7
62
|
* Build URL for fetching PIE element bundles from build service
|
|
8
63
|
*/
|
|
9
64
|
export const getPieElementBundlesUrl = (config, opts) => {
|
|
10
65
|
const elements = config.elements;
|
|
11
|
-
return `${opts.buildServiceBase}/${
|
|
66
|
+
return `${opts.buildServiceBase}/${encodeElementPackageSpecs(Object.values(elements))}/${opts.bundleType}`;
|
|
12
67
|
};
|
|
13
68
|
/**
|
|
14
69
|
* Parse a package name string into its components
|
package/dist/security/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { buildAuthoringAllowList, createDefaultItemMarkupSanitizer, resetPurifierForTesting, sanitizeItemMarkup, type ItemMarkupSanitizer, type SanitizeItemMarkupOptions, } from "./sanitize-item-markup.js";
|
|
2
2
|
export { parseAllowedStyleOrigins, validateExternalStyleUrl, type StyleUrlValidationError, type StyleUrlValidationOk, type StyleUrlValidationOptions, type StyleUrlValidationResult, } from "./validate-style-url.js";
|
|
3
3
|
export { resetSvgSanitizerForTesting, sanitizeSvgIcon, } from "./sanitize-svg-icon.js";
|
|
4
|
-
export {
|
|
4
|
+
export { sanitizeStyleAttribute } from "./sanitize-style-attribute.js";
|
|
5
|
+
export { isOverwideImageWrapMutation, wrapOverwideImages, wrapOverwideImagesInElement, } from "./wrap-overwide-images.js";
|
|
5
6
|
export { wrapModelRichContent } from "./wrap-model-rich-content.js";
|
|
6
|
-
export { wrapOverwideTables, wrapOverwideTablesInElement, } from "./wrap-overwide-tables.js";
|
|
7
|
+
export { isOverwideTableWrapMutation, wrapOverwideTables, wrapOverwideTablesInElement, } from "./wrap-overwide-tables.js";
|
package/dist/security/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { buildAuthoringAllowList, createDefaultItemMarkupSanitizer, resetPurifierForTesting, sanitizeItemMarkup, } from "./sanitize-item-markup.js";
|
|
2
2
|
export { parseAllowedStyleOrigins, validateExternalStyleUrl, } from "./validate-style-url.js";
|
|
3
3
|
export { resetSvgSanitizerForTesting, sanitizeSvgIcon, } from "./sanitize-svg-icon.js";
|
|
4
|
-
export {
|
|
4
|
+
export { sanitizeStyleAttribute } from "./sanitize-style-attribute.js";
|
|
5
|
+
export { isOverwideImageWrapMutation, wrapOverwideImages, wrapOverwideImagesInElement, } from "./wrap-overwide-images.js";
|
|
5
6
|
export { wrapModelRichContent } from "./wrap-model-rich-content.js";
|
|
6
|
-
export { wrapOverwideTables, wrapOverwideTablesInElement, } from "./wrap-overwide-tables.js";
|
|
7
|
+
export { isOverwideTableWrapMutation, wrapOverwideTables, wrapOverwideTablesInElement, } from "./wrap-overwide-tables.js";
|
|
@@ -13,6 +13,15 @@ export const SANITIZER_FORBIDDEN_TAGS = [
|
|
|
13
13
|
"form",
|
|
14
14
|
"meta",
|
|
15
15
|
"link",
|
|
16
|
+
// A <style> element is a document-global stylesheet. The item player renders
|
|
17
|
+
// in light DOM (`shadow: "none"`), so a <style> in authored markup restyles
|
|
18
|
+
// host chrome outside the item. DOMPurify's defaults already drop a
|
|
19
|
+
// top-level HTML <style>; the SVG profile keeps one, whose rules apply
|
|
20
|
+
// document-wide all the same — verified in Chromium 2026-08-30, where
|
|
21
|
+
// `<svg><style>` passed this list and hid an element outside the player.
|
|
22
|
+
// `style` is in DOMPurify's default FORBID_CONTENTS, so the CSS text is
|
|
23
|
+
// dropped with the tag rather than surfacing as item text.
|
|
24
|
+
"style",
|
|
16
25
|
// <foreignObject> inside an <svg> is a well-known escape hatch back into
|
|
17
26
|
// HTML context.
|
|
18
27
|
"foreignobject",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import DOMPurify from "dompurify";
|
|
11
11
|
import { SANITIZER_FORBIDDEN_ATTRS, SANITIZER_FORBIDDEN_TAGS, } from "./sanitize-forbidden-lists.js";
|
|
12
|
+
import { installStyleAttributeHook } from "./sanitize-style-attribute.js";
|
|
12
13
|
import { wrapOverwideImages } from "./wrap-overwide-images.js";
|
|
13
14
|
import { wrapOverwideTables } from "./wrap-overwide-tables.js";
|
|
14
15
|
// Attributes every PIE element / wrapper is allowed to carry.
|
|
@@ -53,6 +54,9 @@ function resolvePurifier() {
|
|
|
53
54
|
typeof factory === "function"
|
|
54
55
|
? factory(window)
|
|
55
56
|
: DOMPurify;
|
|
57
|
+
// `style` is URI-safe to DOMPurify, so nothing inside it is inspected
|
|
58
|
+
// without this. See sanitize-style-attribute.ts.
|
|
59
|
+
installStyleAttributeHook(purifierInstance);
|
|
56
60
|
return purifierInstance;
|
|
57
61
|
}
|
|
58
62
|
/**
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declaration-level filter for the `style` attribute on authored markup and
|
|
3
|
+
* tool icons.
|
|
4
|
+
*
|
|
5
|
+
* DOMPurify lists `style` among its URI-safe attributes, so it permits the
|
|
6
|
+
* attribute and inspects nothing inside it. Two things in an inline style
|
|
7
|
+
* therefore reach the page unchecked: a URL-fetching function, which makes the
|
|
8
|
+
* browser request an arbitrary origin every time the item renders and reports
|
|
9
|
+
* back which learner saw which item, and `position: fixed`, which leaves the
|
|
10
|
+
* item's box and covers the host page — the item player renders in light DOM
|
|
11
|
+
* (`shadow: "none"`), so nothing else confines it.
|
|
12
|
+
*
|
|
13
|
+
* What stays permitted is the point of filtering declarations rather than
|
|
14
|
+
* dropping the attribute: authored items use inline styles for ordinary
|
|
15
|
+
* per-element presentation, and `position: absolute` in particular is
|
|
16
|
+
* load-bearing for accessibility — MathJax's `mjx-assistive-mml` carries
|
|
17
|
+
* `position: absolute; width: 1px; height: 1px; overflow: hidden` to expose
|
|
18
|
+
* MathML to a screen reader while hiding it visually.
|
|
19
|
+
*
|
|
20
|
+
* `position: absolute` and `position: sticky` are deliberately left alone.
|
|
21
|
+
* Sticky cannot leave its containing block, so it is not an escape. Absolute
|
|
22
|
+
* can, when no ancestor between the node and the viewport is positioned, and
|
|
23
|
+
* closing that means making the player's own container a containing block
|
|
24
|
+
* rather than filtering the declaration — a change that re-anchors every
|
|
25
|
+
* absolutely positioned node a PIE element renders, so it is a separate
|
|
26
|
+
* decision with its own visual review.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Filter one `style` attribute value.
|
|
30
|
+
*
|
|
31
|
+
* Returns the input unchanged when nothing needs removing, so authored markup
|
|
32
|
+
* keeps its own spelling — shorthands stay shorthands — in every case but the
|
|
33
|
+
* one that carries something forbidden.
|
|
34
|
+
*/
|
|
35
|
+
export declare function sanitizeStyleAttribute(value: unknown, doc: Document | null | undefined): string;
|
|
36
|
+
/** The subset of a DOMPurify instance this hook needs. */
|
|
37
|
+
export interface StyleAttributeHookTarget {
|
|
38
|
+
addHook?: (entryPoint: string, hook: (node: unknown, data?: unknown) => void) => void;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Install the filter on a DOMPurify instance.
|
|
42
|
+
*
|
|
43
|
+
* `afterSanitizeAttributes` rather than a post-pass over the output string:
|
|
44
|
+
* DOMPurify has already parsed the markup at that point, so the declarations
|
|
45
|
+
* are filtered on the node it is about to serialize instead of costing another
|
|
46
|
+
* DOM round-trip.
|
|
47
|
+
*/
|
|
48
|
+
export declare function installStyleAttributeHook(purifier: StyleAttributeHookTarget): void;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declaration-level filter for the `style` attribute on authored markup and
|
|
3
|
+
* tool icons.
|
|
4
|
+
*
|
|
5
|
+
* DOMPurify lists `style` among its URI-safe attributes, so it permits the
|
|
6
|
+
* attribute and inspects nothing inside it. Two things in an inline style
|
|
7
|
+
* therefore reach the page unchecked: a URL-fetching function, which makes the
|
|
8
|
+
* browser request an arbitrary origin every time the item renders and reports
|
|
9
|
+
* back which learner saw which item, and `position: fixed`, which leaves the
|
|
10
|
+
* item's box and covers the host page — the item player renders in light DOM
|
|
11
|
+
* (`shadow: "none"`), so nothing else confines it.
|
|
12
|
+
*
|
|
13
|
+
* What stays permitted is the point of filtering declarations rather than
|
|
14
|
+
* dropping the attribute: authored items use inline styles for ordinary
|
|
15
|
+
* per-element presentation, and `position: absolute` in particular is
|
|
16
|
+
* load-bearing for accessibility — MathJax's `mjx-assistive-mml` carries
|
|
17
|
+
* `position: absolute; width: 1px; height: 1px; overflow: hidden` to expose
|
|
18
|
+
* MathML to a screen reader while hiding it visually.
|
|
19
|
+
*
|
|
20
|
+
* `position: absolute` and `position: sticky` are deliberately left alone.
|
|
21
|
+
* Sticky cannot leave its containing block, so it is not an escape. Absolute
|
|
22
|
+
* can, when no ancestor between the node and the viewport is positioned, and
|
|
23
|
+
* closing that means making the player's own container a containing block
|
|
24
|
+
* rather than filtering the declaration — a change that re-anchors every
|
|
25
|
+
* absolutely positioned node a PIE element renders, so it is a separate
|
|
26
|
+
* decision with its own visual review.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* CSS functions that make the browser fetch a URL. Serialized CSSOM values are
|
|
30
|
+
* matched against this, so an escaped spelling (`\75 rl(`) is normalized to
|
|
31
|
+
* `url(` before it gets here.
|
|
32
|
+
*/
|
|
33
|
+
const URL_FUNCTION_REGEX = /(?:^|[^\w-])(?:url|image-set|-webkit-image-set|src)\s*\(/i;
|
|
34
|
+
/**
|
|
35
|
+
* Cheap gate on the raw attribute so the common case costs one regex and no
|
|
36
|
+
* CSSOM parse. `position` is included because that check needs the parser, and
|
|
37
|
+
* a backslash because it introduces a CSS escape, which is exactly how a
|
|
38
|
+
* forbidden token hides from a raw-string match.
|
|
39
|
+
*/
|
|
40
|
+
const NEEDS_INSPECTION_REGEX = /url\s*\(|image-set\s*\(|src\s*\(|position|\\/i;
|
|
41
|
+
function isForbiddenDeclaration(property, value) {
|
|
42
|
+
if (URL_FUNCTION_REGEX.test(value))
|
|
43
|
+
return true;
|
|
44
|
+
// `position` is a shorthand for nothing, so the CSSOM reports it verbatim.
|
|
45
|
+
return property.toLowerCase() === "position" && /\bfixed\b/i.test(value);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Rebuild the attribute from the parsed declarations, dropping the forbidden
|
|
49
|
+
* ones. Anything the engine failed to parse is dropped with them: an unparsed
|
|
50
|
+
* declaration cannot be inspected, and this path only runs on input that
|
|
51
|
+
* already tripped the gate.
|
|
52
|
+
*/
|
|
53
|
+
function rebuildFromCssom(value, doc) {
|
|
54
|
+
const probe = doc.createElement("span");
|
|
55
|
+
probe.setAttribute("style", value);
|
|
56
|
+
const declarations = probe.style;
|
|
57
|
+
const kept = [];
|
|
58
|
+
for (let index = 0; index < declarations.length; index += 1) {
|
|
59
|
+
const property = declarations.item(index);
|
|
60
|
+
if (!property)
|
|
61
|
+
continue;
|
|
62
|
+
const propertyValue = declarations.getPropertyValue(property);
|
|
63
|
+
if (isForbiddenDeclaration(property, propertyValue))
|
|
64
|
+
continue;
|
|
65
|
+
const priority = declarations.getPropertyPriority(property);
|
|
66
|
+
kept.push(`${property}: ${propertyValue}${priority ? ` !${priority}` : ""}`);
|
|
67
|
+
}
|
|
68
|
+
return kept.join("; ");
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Filter one `style` attribute value.
|
|
72
|
+
*
|
|
73
|
+
* Returns the input unchanged when nothing needs removing, so authored markup
|
|
74
|
+
* keeps its own spelling — shorthands stay shorthands — in every case but the
|
|
75
|
+
* one that carries something forbidden.
|
|
76
|
+
*/
|
|
77
|
+
export function sanitizeStyleAttribute(value, doc) {
|
|
78
|
+
if (typeof value !== "string" || value.length === 0)
|
|
79
|
+
return "";
|
|
80
|
+
if (!doc)
|
|
81
|
+
return value;
|
|
82
|
+
if (!NEEDS_INSPECTION_REGEX.test(value))
|
|
83
|
+
return value;
|
|
84
|
+
const probe = doc.createElement("span");
|
|
85
|
+
probe.setAttribute("style", value);
|
|
86
|
+
const declarations = probe.style;
|
|
87
|
+
let hasForbidden = false;
|
|
88
|
+
for (let index = 0; index < declarations.length; index += 1) {
|
|
89
|
+
const property = declarations.item(index);
|
|
90
|
+
if (!property)
|
|
91
|
+
continue;
|
|
92
|
+
if (isForbiddenDeclaration(property, declarations.getPropertyValue(property))) {
|
|
93
|
+
hasForbidden = true;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// A raw value carrying a URL function the engine did not parse into a
|
|
98
|
+
// declaration still goes through the rebuild, which drops it.
|
|
99
|
+
if (!hasForbidden && !URL_FUNCTION_REGEX.test(value))
|
|
100
|
+
return value;
|
|
101
|
+
return rebuildFromCssom(value, doc);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Install the filter on a DOMPurify instance.
|
|
105
|
+
*
|
|
106
|
+
* `afterSanitizeAttributes` rather than a post-pass over the output string:
|
|
107
|
+
* DOMPurify has already parsed the markup at that point, so the declarations
|
|
108
|
+
* are filtered on the node it is about to serialize instead of costing another
|
|
109
|
+
* DOM round-trip.
|
|
110
|
+
*/
|
|
111
|
+
export function installStyleAttributeHook(purifier) {
|
|
112
|
+
if (typeof purifier.addHook !== "function")
|
|
113
|
+
return;
|
|
114
|
+
purifier.addHook("afterSanitizeAttributes", (node) => {
|
|
115
|
+
const element = node;
|
|
116
|
+
if (!element || typeof element.getAttribute !== "function")
|
|
117
|
+
return;
|
|
118
|
+
const raw = element.getAttribute("style");
|
|
119
|
+
if (!raw)
|
|
120
|
+
return;
|
|
121
|
+
const filtered = sanitizeStyleAttribute(raw, element.ownerDocument);
|
|
122
|
+
if (filtered === raw)
|
|
123
|
+
return;
|
|
124
|
+
if (filtered)
|
|
125
|
+
element.setAttribute("style", filtered);
|
|
126
|
+
else
|
|
127
|
+
element.removeAttribute("style");
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import DOMPurify from "dompurify";
|
|
13
13
|
import { SANITIZER_FORBIDDEN_ATTRS, SANITIZER_FORBIDDEN_TAGS, } from "./sanitize-forbidden-lists.js";
|
|
14
|
+
import { installStyleAttributeHook } from "./sanitize-style-attribute.js";
|
|
14
15
|
let svgPurifierInstance = null;
|
|
15
16
|
function resolveSvgPurifier() {
|
|
16
17
|
if (svgPurifierInstance)
|
|
@@ -22,6 +23,7 @@ function resolveSvgPurifier() {
|
|
|
22
23
|
typeof factory === "function"
|
|
23
24
|
? factory(window)
|
|
24
25
|
: DOMPurify;
|
|
26
|
+
installStyleAttributeHook(svgPurifierInstance);
|
|
25
27
|
return svgPurifierInstance;
|
|
26
28
|
}
|
|
27
29
|
const FORBIDDEN_TAGS = SANITIZER_FORBIDDEN_TAGS;
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
* - When `allowedOrigins` is non-empty, the URL's origin must match one
|
|
9
9
|
* of the listed origins. This lets hosts restrict style loading to a
|
|
10
10
|
* known CDN allow-list.
|
|
11
|
+
* - With no allow-list configured, only same-origin URLs pass. The reachable
|
|
12
|
+
* input here is authored — `itemConfig.resources.stylesheets[*].url` — so an
|
|
13
|
+
* open default let an item pull page-wide CSS from any origin it named, and
|
|
14
|
+
* the cross-origin branch in the player is the one that cannot be scoped
|
|
15
|
+
* (CSS the browser applies from a `<link>` rather than text the player
|
|
16
|
+
* fetched and rewrote). Naming an origin in `allowed-style-origins` is a
|
|
17
|
+
* host's opt-in to that.
|
|
11
18
|
*/
|
|
12
19
|
export type StyleUrlValidationOk = {
|
|
13
20
|
ok: true;
|
|
@@ -20,6 +27,12 @@ export type StyleUrlValidationError = {
|
|
|
20
27
|
};
|
|
21
28
|
export type StyleUrlValidationResult = StyleUrlValidationOk | StyleUrlValidationError;
|
|
22
29
|
export interface StyleUrlValidationOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Document URL the stylesheet URL resolves against, and the origin a URL is
|
|
32
|
+
* compared to when no `allowedOrigins` are configured. Omitting it while
|
|
33
|
+
* supplying no allow-list leaves no origin to compare against, so
|
|
34
|
+
* cross-origin cannot be ruled out and the URL is rejected.
|
|
35
|
+
*/
|
|
23
36
|
baseUrl?: string;
|
|
24
37
|
allowedOrigins?: string[];
|
|
25
38
|
}
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
* - When `allowedOrigins` is non-empty, the URL's origin must match one
|
|
9
9
|
* of the listed origins. This lets hosts restrict style loading to a
|
|
10
10
|
* known CDN allow-list.
|
|
11
|
+
* - With no allow-list configured, only same-origin URLs pass. The reachable
|
|
12
|
+
* input here is authored — `itemConfig.resources.stylesheets[*].url` — so an
|
|
13
|
+
* open default let an item pull page-wide CSS from any origin it named, and
|
|
14
|
+
* the cross-origin branch in the player is the one that cannot be scoped
|
|
15
|
+
* (CSS the browser applies from a `<link>` rather than text the player
|
|
16
|
+
* fetched and rewrote). Naming an origin in `allowed-style-origins` is a
|
|
17
|
+
* host's opt-in to that.
|
|
11
18
|
*/
|
|
12
19
|
export function validateExternalStyleUrl(url, options = {}) {
|
|
13
20
|
if (typeof url !== "string" || url.length === 0) {
|
|
@@ -38,11 +45,38 @@ export function validateExternalStyleUrl(url, options = {}) {
|
|
|
38
45
|
};
|
|
39
46
|
}
|
|
40
47
|
const allowed = options.allowedOrigins ?? [];
|
|
41
|
-
if (allowed.length > 0
|
|
48
|
+
if (allowed.length > 0) {
|
|
49
|
+
if (!allowed.includes(resolvedUrl.origin)) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
reason: "disallowed-origin",
|
|
53
|
+
message: `External stylesheet origin ${resolvedUrl.origin} is not in the configured allow-list.`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, resolvedUrl };
|
|
57
|
+
}
|
|
58
|
+
// No allow-list: same-origin only.
|
|
59
|
+
let baseOrigin = null;
|
|
60
|
+
if (options.baseUrl) {
|
|
61
|
+
try {
|
|
62
|
+
baseOrigin = new URL(options.baseUrl).origin;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
baseOrigin = null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (baseOrigin === null) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
reason: "disallowed-origin",
|
|
72
|
+
message: "External stylesheet origin cannot be checked: no allow-list is configured and no usable baseUrl was supplied. Pass allowedOrigins to permit a cross-origin stylesheet.",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (resolvedUrl.origin !== baseOrigin) {
|
|
42
76
|
return {
|
|
43
77
|
ok: false,
|
|
44
78
|
reason: "disallowed-origin",
|
|
45
|
-
message: `External stylesheet origin ${resolvedUrl.origin} is
|
|
79
|
+
message: `External stylesheet origin ${resolvedUrl.origin} is cross-origin and no allow-list is configured. Add it to \`allowed-style-origins\` to permit it.`,
|
|
46
80
|
};
|
|
47
81
|
}
|
|
48
82
|
return { ok: true, resolvedUrl };
|
|
@@ -49,3 +49,10 @@ export declare function wrapOverwideImagesInElement(root: Element, options?: Wra
|
|
|
49
49
|
* to wrap element-rendered images.
|
|
50
50
|
*/
|
|
51
51
|
export declare function wrapOverwideImages(markup: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* True when `record` mentions nothing but the output of
|
|
54
|
+
* {@link wrapOverwideImagesInElement} — a `pie-image-scroll` wrapper, or an
|
|
55
|
+
* `<img>` moving inside one. An observer-driven caller ignores such a record so
|
|
56
|
+
* its own wrap does not schedule a second pass.
|
|
57
|
+
*/
|
|
58
|
+
export declare function isOverwideImageWrapMutation(record: MutationRecord): boolean;
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* The wrapping itself lives in `./wrap-overwide.js`, shared with the table
|
|
27
27
|
* wrapper: only the four values below and the accessible name differ.
|
|
28
28
|
*/
|
|
29
|
-
import { wrapOverwideInElement, wrapOverwideMarkup, } from "./wrap-overwide.js";
|
|
29
|
+
import { isOverwideWrapMutation, wrapOverwideInElement, wrapOverwideMarkup, } from "./wrap-overwide.js";
|
|
30
30
|
function buildAriaLabel(image) {
|
|
31
31
|
const alt = image.getAttribute("alt");
|
|
32
32
|
const trimmed = alt ? alt.trim() : "";
|
|
@@ -64,3 +64,12 @@ export function wrapOverwideImagesInElement(root, options = {}) {
|
|
|
64
64
|
export function wrapOverwideImages(markup) {
|
|
65
65
|
return wrapOverwideMarkup(markup, IMAGE_SPEC);
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* True when `record` mentions nothing but the output of
|
|
69
|
+
* {@link wrapOverwideImagesInElement} — a `pie-image-scroll` wrapper, or an
|
|
70
|
+
* `<img>` moving inside one. An observer-driven caller ignores such a record so
|
|
71
|
+
* its own wrap does not schedule a second pass.
|
|
72
|
+
*/
|
|
73
|
+
export function isOverwideImageWrapMutation(record) {
|
|
74
|
+
return isOverwideWrapMutation(record, IMAGE_SPEC);
|
|
75
|
+
}
|
|
@@ -35,3 +35,10 @@ export type WrapOverwideTablesInElementOptions = WrapOverwideOptions;
|
|
|
35
35
|
*/
|
|
36
36
|
export declare function wrapOverwideTablesInElement(root: Element, options?: WrapOverwideTablesInElementOptions): number;
|
|
37
37
|
export declare function wrapOverwideTables(markup: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* True when `record` mentions nothing but the output of
|
|
40
|
+
* {@link wrapOverwideTablesInElement} — a `pie-table-scroll` wrapper, or a
|
|
41
|
+
* `<table>` moving inside one. An observer-driven caller ignores such a record
|
|
42
|
+
* so its own wrap does not schedule a second pass.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isOverwideTableWrapMutation(record: MutationRecord): boolean;
|