Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 18 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Architecture

Matreshka.js makes plain object properties reactive and binds them to DOM nodes the page already has.

## The parts

- **Definitions** (`src/_core/`). Each object the library touches gets a definition, kept in a `WeakMap`: its events, its properties (value, getter, setter, bindings) and an id. Nothing is stored on the object itself. The tables have no prototype, so a key such as `__proto__` is a key like any other.
- **Properties** (`src/_core/defineprop.ts`, `src/set.ts`). A watched property becomes an accessor. Assigning it (or calling `set`) stores the value and fires `change:KEY`, along with the internal events bindings and `calc` listen to. A setter the user's class defines is kept.
- **Events** (`src/on/`, `src/off/`, `src/trigger/`). Object events and DOM events (`click::KEY`) share one API. A path (`a.b@change:c`) delegates a listener to a nested object; when a branch is replaced, the listeners move to the new branch.
- **Bindings** (`src/bindnode/`, `src/binders/`). `bindNode` ties a property to nodes. A binder says how: which DOM event to listen to, how to read the node (`getValue`) and how to write it (`setValue`). Default binders cover form fields and output elements; `Matreshka.binders` has the rest (`text`, `html`, `attr`, `className`, `display`, …). `parseBindings` turns `{{key}}` in markup into text bindings.
- **Computed values** (`src/calc/`). `calc` sets a property from other properties, nested ones included, and updates it when they change.
- **Collections** (`src/object/`, `src/array/`). `Matreshka.Object` tracks a set of data keys. `Matreshka.Array` has the native array methods, keeps its items as instances of its `Model`, and renders them into the bound container with `itemRenderer`: on change it diffs the items and moves, adds or removes nodes (`trackBy` keeps them by id).
- **Router and form parser** (`src/router/`, `src/parse-form/`). The router binds properties to the parts of the URL: the hash by default, or the History API path. `parseForm` binds a form's fields by their names.
- **DOM layer** (`src/_dom/`). A small selector, HTML-parsing and delegated-event helper, so there is no DOM library dependency.

## Builds

`npm run build` compiles `src/` with TypeScript, then Babel to ES5 for IE 11, into `dist/` (ES modules with type declarations). Rollup bundles the entry points into `dist/matreshka.js` and `dist/matreshka.min.js` for a script tag, and `dist/matreshka.min.mjs`, one ES module for a CDN, built from the code before Babel. The build is repeatable: building the same commit twice gives the same files, and the packed tarball matches the published one.
5 changes: 5 additions & 0 deletions CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Code of conduct

This project follows the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/), in every project space: issues, pull requests, discussions, and anywhere someone represents the project.

Report unacceptable behavior to the maintainer, Andrey Gubanov, at andrey.a.gubanov@gmail.com. Reports are kept private. The maintainer applies the Covenant's enforcement guidelines: a correction, a warning, a temporary ban or a permanent ban.
14 changes: 13 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,19 @@ The same gate CI runs:
npm run lint && npm run typecheck && npm run test:coverage && npm run build && npm run check:dist
```

A fix or a feature comes with a test; a fix's test fails on the code before it. Lint warnings fail the gate (`npx biome check --write .` fixes most of them).
A fix or a feature comes with a test in the automated suite; a fix's test fails on the code before it. Coverage stays at 100% (the thresholds in `vitest.config.mjs` fail the gate below it).

## Coding style

TypeScript in `strict` mode, formatted and linted by [Biome](https://biomejs.dev) with its recommended rules (`biome.json`). CI runs both on every push and pull request, and a lint warning fails the gate (`npx biome check --write .` fixes most of them).

## Sign-off

By sending a pull request you certify the [Developer Certificate of Origin 1.1](https://developercertificate.org): you wrote the change, or have the right to submit it, under the project's MIT license. Sign your commits off with `git commit -s`.

## Project documents

[GOVERNANCE.md](./GOVERNANCE.md) (decisions, roles, continuity), [ROADMAP.md](./ROADMAP.md), [ARCHITECTURE.md](./ARCHITECTURE.md), [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md).

## Layout

Expand Down
20 changes: 20 additions & 0 deletions GOVERNANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Governance

## How decisions are made

Matreshka.js has one maintainer, [Andrey Gubanov](https://github.com/finom), who wrote it in 2013. The maintainer decides what goes in: features, fixes, releases, the supported browsers. Anyone can propose a change in an issue or a pull request; the discussion happens there, in public, and the decision is written in the issue or the pull request.

## Roles

- **Maintainer** (Andrey Gubanov). Reviews and merges pull requests, answers issues and security reports ([SECURITY.md](./SECURITY.md)), runs the releases (`npm run patch|minor|major`, see [CONTRIBUTING.md](./CONTRIBUTING.md)), keeps the docs at [matreshka.js.org](https://matreshka.js.org) current with the code, and keeps the dependencies up to date (Dependabot).
- **Contributor** (anyone). Opens issues and pull requests. A pull request passes the gate in [CONTRIBUTING.md](./CONTRIBUTING.md) and follows the [code of conduct](./CODE_OF_CONDUCT.md).

## Continuity

The project can go on if the maintainer can't:

- The code, the issues, the docs, the docs deploy and the release workflow all live in this GitHub repository; nothing is kept on the maintainer's machine. Releases publish to npm from the repository's workflow (trusted publishing), with no npm token to hand over.
- The maintainer's GitHub account has a designated [account successor](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/repository-access-and-collaboration/maintaining-ownership-continuity-of-your-personal-accounts-repositories), who can archive the repository or transfer it to a new maintainer.
- The license is MIT: anyone can fork the project and go on.

The bus factor is 1: there is one maintainer.
19 changes: 19 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Roadmap

For the next year (to the end of 2027), Matreshka.js 3 is maintained, not grown.

## Planned

- Bug fixes, each with a regression test, released as patches.
- Security fixes ([SECURITY.md](./SECURITY.md)).
- The same browsers as the latest jQuery, Internet Explorer 11 included, for all of 3.x.
- Dependency updates (Dependabot), the gates and CodeQL green on every change.
- Docs in English, Ukrainian and Russian, kept current with each release.

## Not planned

- A virtual DOM, components, JSX or templates compiled at build time.
- A required build step: the script tag stays a first-class way to use it.
- Server-side rendering.
- Runtime dependencies.
- Breaking changes within 3.x.
28 changes: 27 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,33 @@ Out of scope: the `html` binder and `itemRenderer`/`renderer` templates, which w

## Verifying a release

Every version is published from GitHub Actions with npm provenance. `npm audit signatures` in a project that installs it checks the signature and the attestation.
Every version is published from GitHub Actions through npm trusted publishing, with an npm provenance attestation signed through Sigstore. In a project that installs it, `npm audit signatures` checks the registry signature and the attestation; npm fetches the public keys itself (`https://registry.npmjs.org/-/npm/v1/keys`). Each GitHub Release carries the same attestation (`v<version>.intoto.jsonl`).

## Handling a report

1. The maintainer acknowledges the report within 14 days.
2. The maintainer reproduces it and agrees on the impact with the reporter in the private advisory.
3. The fix gets a regression test that fails on the code before it, and ships as a patch release of the latest version, within 60 days for a confirmed vulnerability.
4. The advisory is published with the release, with a CVE when one applies, and the CHANGELOG entry links it.

Reporters are credited in the advisory and the CHANGELOG entry, unless they ask not to be.

## What you can expect

- A value bound with a text binder (`text`, the default binders for inputs and outputs, `{{key}}` in markup) is written as text, never parsed as HTML.
- A key named `__proto__`, `constructor` or `toString` (`set`, `setData`) is stored as an own key, and a path through `__proto__` (`calc`, `bindNode`, `on('a.b@…')`) never reaches `Object.prototype`.
- The library runs no code from strings (no `eval`, no `new Function`), so it works under a Content Security Policy without `unsafe-eval`.
- No runtime dependencies.

What it does not do: the `html` binder and the `itemRenderer`/`renderer` templates write HTML by design, and an attribute binder writes the value as given (a `javascript:` URL in an `href` included). Escaping or checking untrusted data before it reaches them is the application's job.

## Assurance case

- **Threat model.** The attacker controls data the application puts into Matreshka objects: field values typed into a page, JSON from a server, keys of that JSON. The attacker does not control the application's code, its templates or its binder choices.
- **Trust boundary.** Values and keys are untrusted; code, selectors, templates and binders are trusted. The library treats every value as data and every key as a plain name.
- **Why the requirements hold.** Text binders write through `textContent`, `value` and similar DOM properties, never `innerHTML`. Per-object state lives in a `WeakMap`, in tables made with `Object.create(null)`, and a `__proto__` step in a key or a path reads and writes only an own property, so no key reaches `Object.prototype`. There is no code path that turns a string into code. Secure design principles: least privilege (the library only touches the nodes it is given), fail-safe defaults (text, not HTML, unless the `html` binder is chosen), economy of mechanism (no dependencies, one small DOM layer).
- **Weaknesses countered.** CWE-79 (XSS) through the text-only defaults; CWE-1321 (prototype pollution) through null-prototype tables and own-property `__proto__` steps; CWE-1333 (ReDoS) through regexes CodeQL checks for polynomial backtracking; CWE-94 (code injection) through no string evaluation; supply-chain attacks through zero dependencies, actions pinned by SHA, trusted publishing and provenance.
- **Evidence.** Tests for each of these, in `test/spec/regressions/` (`codeql_2026_10_17_spec.js` holds the CodeQL findings) and the property tests in `test/spec/properties_spec.js`; 100% coverage; CodeQL on every push and pull request, with no open alerts; OpenSSF Scorecard on `main`.

## Terms

Expand Down
118 changes: 118 additions & 0 deletions test/spec/security_claims_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// one test per claim in SECURITY.md, "What you can expect"
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import MatreshkaObject from 'src/object';
import binders from 'src/binders';
import set from 'src/set';
import calc from 'src/calc';
import on from 'src/on';
import bindNode from 'src/bindnode';
import parseBindings from 'src/parsebindings';

// bound nodes are written on the next timer tick
const tick = () => new Promise((resolve) => setTimeout(resolve, 10));

const payload = '<img src=x onerror="window.__mkXss=1">';

describe('SECURITY.md: text binders write text', () => {
afterEach(() => {
delete window.__mkXss;
});

it('the text binder', async () => {
const node = document.createElement('span');
const obj = {};
bindNode(obj, 'x', node, binders.text());
obj.x = payload;
await tick();
expect(node.textContent).toBe(payload);
expect(node.children.length).toBe(0);
expect(window.__mkXss).toBe(undefined);
});

it('the default binders of input, textarea and output', async () => {
const nodes = ['input', 'textarea', 'output'].map((tag) => document.createElement(tag));
const obj = {};
for (const node of nodes) bindNode(obj, `x${node.tagName}`, node);
for (const node of nodes) obj[`x${node.tagName}`] = payload;
await tick();
for (const node of nodes) {
expect(node.value).toBe(payload);
expect(node.children.length).toBe(0);
}
});

it('{{key}} in markup', async () => {
const node = document.createElement('div');
node.innerHTML = '<p>{{x}}</p>';
const obj = {};
parseBindings(obj, node);
obj.x = payload;
await tick();
expect(node.querySelector('p').textContent).toBe(payload);
expect(node.querySelector('img')).toBe(null);
});
});

describe('SECURITY.md: keys and paths never reach Object.prototype', () => {
afterEach(() => {
for (const name of ['x', 'y', 'polluted']) delete Object.prototype[name];
});

it('__proto__, constructor and toString are stored as own keys', () => {
for (const name of ['__proto__', 'constructor', 'toString']) {
const plain = {};
set(plain, name, { polluted: true });
const obj = new MatreshkaObject();
obj.setData({ [name]: { polluted: true } });
for (const target of [plain, obj]) {
// a plain object gets a data property, a Matreshka object an accessor
const descriptor = Object.getOwnPropertyDescriptor(target, name);
expect(descriptor.get ? descriptor.get.call(target) : descriptor.value, name).toEqual({ polluted: true });
}
expect(Object.getPrototypeOf(plain)).toBe(Object.prototype);
}
expect(Object.prototype.polluted).toBe(undefined);
expect({}.toString).toBe(Object.prototype.toString);
expect({}.constructor).toBe(Object);
});

it('a path through __proto__ in set, calc, bindNode and on', () => {
const obj = new MatreshkaObject();
set(obj, '__proto__', { x: 1 });
calc(obj, 'z', '__proto__.x', (v) => v);
on(obj, '__proto__@change:y', () => {});
bindNode(obj, '__proto__.y', document.createElement('input'));
const plain = {};
calc(plain, 'z', '__proto__.x', (v) => v);
bindNode(plain, '__proto__.y', document.createElement('input'));
expect(Object.prototype.x).toBe(undefined);
expect(Object.prototype.y).toBe(undefined);
expect(Object.hasOwn(Object.prototype, 'x')).toBe(false);
expect(Object.hasOwn(Object.prototype, 'y')).toBe(false);
});
});

describe('SECURITY.md: no code from strings', () => {
it('src/ has no eval and no Function constructor', () => {
const files = [];
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) walk(path);
else if (path.endsWith('.ts')) files.push(path);
}
};
walk('src');
expect(files.length).toBeGreaterThan(50);
for (const file of files) {
const code = readFileSync(file, 'utf8');
expect(code, file).not.toMatch(/\beval\s*\(|\bnew\s+Function\b|(?<![\w.])Function\s*\(/);
}
});

it('package.json has no runtime dependencies', () => {
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
expect(pkg.dependencies).toBe(undefined);
});
});
Loading