diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..bf7832f5 --- /dev/null +++ b/ARCHITECTURE.md @@ -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. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..bcb58622 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d7f978e3..8f075ec2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 00000000..73dadd85 --- /dev/null +++ b/GOVERNANCE.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..8503fcf6 --- /dev/null +++ b/ROADMAP.md @@ -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. diff --git a/SECURITY.md b/SECURITY.md index f3955d5c..13f037ed 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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.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 diff --git a/test/spec/security_claims_spec.js b/test/spec/security_claims_spec.js new file mode 100644 index 00000000..e4e24b29 --- /dev/null +++ b/test/spec/security_claims_spec.js @@ -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 = ''; + +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 = '

{{x}}

'; + 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|(? { + const pkg = JSON.parse(readFileSync('package.json', 'utf8')); + expect(pkg.dependencies).toBe(undefined); + }); +});