From 8e962eb8e7069a69fc2ede13328ea3bef38f109a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Gim=C3=A9nez=20Moya?= Date: Mon, 27 Jul 2026 01:01:59 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Web=20responsiva=20en=20m=C3=B3vil:=20men?= =?UTF-8?q?=C3=BA=20hamburguesa=20para=20las=20acciones,=20opciones=20de?= =?UTF-8?q?=20evaluaci=C3=B3n=20plegadas=20tras=20un=20engranaje=20CON=20i?= =?UTF-8?q?ndicador,=20y=20paneo=20t=C3=A1ctil=20garantizado=20en=20todo?= =?UTF-8?q?=20el=20MathJax=20de=20celdas=20y=20paneles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En un smartphone vertical la cabecera intentaba meter 8 controles en línea y la barra de entrada empujaba 4 selects delante del campo. Ahora (≤720px): - CABECERA: título + botón ☰. Las acciones (idioma, tema, exportar, importar, variables, funciones, ejemplos, limpiar) viven en un menú desplegable bajo la cabecera sticky — MISMO nodo .header-buttons recolocado por CSS, así que los handlers no se duplican. Se cierra al elegir una acción, al tocar fuera y con Escape; aria-expanded en el toggle y claves i18n es/en nuevas. - BARRA DE ENTRADA: por defecto solo campo + ⚙️ + Evaluar. El engranaje despliega los 4 selects de modo. COMO LOS SELECTS QUEDAN OCULTOS, un modo no-default cambiaría la semántica sin rastro visible: el engranaje lleva un punto de acento siempre que algún modo difiere de su default (domain=generic, branch=strict, complex=off, numeric=exact), actualizado por listener de change. Con las opciones abiertas la barra crece varias filas: body.options-open amplía el padding-bottom para que la última celda nunca quede tapada (defecto cazado midiendo cardBottom contra barTop en el navegador). - CELDAS: todo mjx-container dentro de tarjetas y paneles panea en horizontal por sí mismo (overflow-x auto + -webkit-overflow-scrolling touch), de modo que una fórmula larga nunca fuerza scroll lateral de la PÁGINA. Los carriles que ya escrolaban (.result-math, .rule-change, .step-math) se verificaron intactos; la regla nueva cubre pasos y paneles sin envoltorio propio. ESCRITORIO (>720px) BYTE-IDÉNTICO: los dos toggles nuevos viven bajo la media query; capturado antes/después a 1280px. VERIFICADO en navegador real a 375×812 con el servidor de dev y el binario release: menú abre/cierra por las tres vías; engranaje pliega/despliega y el punto aparece al poner complex=on; integrate(1/(x^3-2), x) y expand((1+x)^7) renderizan con paneo propio y documentElement.scrollWidth == clientWidth (sin desborde de página) en todos los estados; la celda queda visible sobre la barra con opciones abiertas. Smoke de la web: 4/4 OK. Co-Authored-By: Claude Fable 5 --- web/index.html | 203 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 201 insertions(+), 2 deletions(-) diff --git a/web/index.html b/web/index.html index 28d73fa8..1226744e 100644 --- a/web/index.html +++ b/web/index.html @@ -998,20 +998,116 @@ transform: translateY(0); } + /* Mobile-only controls: the hamburger that collects the header + actions and the gear that folds the mode selects away. Hidden on + desktop, where everything fits inline. */ + .menu-toggle, + .options-toggle { + display: none; + background: rgba(255, 255, 255, 0.1); + border: 1px solid var(--border); + color: var(--text-primary); + border-radius: 8px; + cursor: pointer; + font-size: 1.1em; + padding: 8px 14px; + transition: all 0.2s; + } + + .menu-toggle:hover, + .options-toggle:hover { + background: rgba(255, 255, 255, 0.2); + border-color: var(--accent); + } + + /* A dot on the gear when any mode differs from its default: the + selects are hidden on mobile, and a non-default mode silently + changing semantics must stay visible. */ + .options-toggle.has-nondefault::after { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + margin-left: 6px; + border-radius: 50%; + background: var(--accent); + vertical-align: middle; + } + @media (max-width: 720px) { body { padding-bottom: 155px; } + /* With the options unfolded the input bar grows several rows tall; + the page needs matching clearance or the last card hides + permanently behind it. */ + body.options-open { + padding-bottom: 480px; + } + + /* Header: title + hamburger; the action buttons become a + slide-down menu under the sticky header. */ + .menu-toggle { + display: flex; + align-items: center; + } + + .header-left p { + display: none; + } + + .header-buttons { + display: none; + } + + header.menu-open .header-buttons { + display: flex; + flex-direction: column; + align-items: stretch; + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--bg-secondary); + border-bottom: 2px solid var(--accent); + padding: 10px 14px 14px; + gap: 8px; + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.45); + } + + header.menu-open .header-buttons .header-btn { + justify-content: flex-start; + padding: 12px 14px; + font-size: 1em; + } + + header.menu-open .lang-switch { + align-self: flex-start; + } + .input-wrapper { flex-wrap: wrap; } + /* Mode selects fold behind the gear; the default bar is just + input + gear + Evaluar. */ + .options-toggle { + display: flex; + align-items: center; + height: 48px; + } + .mode-control { - flex: 1 1 150px; + display: none; + flex: 1 1 45%; height: 48px; } + .input-bar.options-open .mode-control { + display: flex; + } + .input-field { flex: 1 1 100%; order: -1; @@ -1020,6 +1116,24 @@ .submit-btn { flex: 1 1 130px; } + + .container { + padding: 14px 12px; + } + } + + /* Every MathJax container inside a card or panel pans horizontally + on its own, so a long formula never forces the PAGE to scroll + sideways and a phone can flick through it with one finger. */ + .expression-card mjx-container, + .steps-container mjx-container, + .variables-panel mjx-container, + .functions-panel mjx-container { + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + padding-bottom: 3px; } /* Loading state */ @@ -1432,7 +1546,13 @@

🧮 ExpliCAS

Simplificador matemático interactivo paso a paso

-
+ +
@@ -1522,6 +1642,12 @@

¡Bienvenido a ExpliCAS!

+ @@ -1602,9 +1728,78 @@

const LANG_MODE_STORAGE_KEY = 'expliCAS_step_language'; // ===== Interface i18n (es/en) — one catalog, driven by the ES/EN language toggle ===== + // --- Mobile chrome: header menu + input options fold ------------- + function toggleHeaderMenu(force) { + const header = document.querySelector("header"); + const open = force !== undefined ? force : !header.classList.contains("menu-open"); + header.classList.toggle("menu-open", open); + document.getElementById("menu-toggle").setAttribute("aria-expanded", String(open)); + } + + function toggleInputOptions() { + const bar = document.querySelector(".input-bar"); + const open = !bar.classList.contains("options-open"); + bar.classList.toggle("options-open", open); + document.body.classList.toggle("options-open", open); + document.getElementById("options-toggle").setAttribute("aria-expanded", String(open)); + } + + // The gear hides the mode selects on mobile; a non-default mode must + // stay visible, so the gear carries a dot whenever one is active. + const MODE_SELECT_DEFAULTS = { + "domain-mode-select": "generic", + "branch-mode-select": "strict", + "complex-mode-select": "off", + "numeric-display-select": "exact", + }; + + function refreshOptionsIndicator() { + const nondefault = Object.entries(MODE_SELECT_DEFAULTS).some( + ([id, def]) => { + const el = document.getElementById(id); + return el && el.value !== def; + } + ); + document + .getElementById("options-toggle") + .classList.toggle("has-nondefault", nondefault); + } + + document.addEventListener("DOMContentLoaded", () => { + // Acting on a menu entry closes the menu; so does tapping outside + // or pressing Escape. + document.getElementById("header-buttons").addEventListener("click", (ev) => { + if (ev.target.closest("button")) { + toggleHeaderMenu(false); + } + }); + document.addEventListener("click", (ev) => { + const header = document.querySelector("header"); + if (header.classList.contains("menu-open") && !header.contains(ev.target)) { + toggleHeaderMenu(false); + } + }); + document.addEventListener("keydown", (ev) => { + if (ev.key === "Escape") { + toggleHeaderMenu(false); + } + }); + for (const id of Object.keys(MODE_SELECT_DEFAULTS)) { + const el = document.getElementById(id); + if (el) { + el.addEventListener("change", refreshOptionsIndicator); + } + } + refreshOptionsIndicator(); + }); + const I18N = { "es": { "header.tagline": "Simplificador matemático interactivo paso a paso", + "header.menu.title": "Menú", + "header.menu.aria_label": "Abrir el menú de acciones", + "input.options_toggle.title": "Opciones de evaluación (modo, rama, complejo, números)", + "input.options_toggle.aria_label": "Mostrar u ocultar las opciones de evaluación", "header.theme_toggle.title": "Cambiar tema", "header.theme_toggle.label": "🌙 Oscuro", "header.export.title": "Exportar sesión como JSON", @@ -1686,6 +1881,10 @@

}, "en": { "header.tagline": "Interactive Step-by-Step Math Simplifier", + "header.menu.title": "Menu", + "header.menu.aria_label": "Open the actions menu", + "input.options_toggle.title": "Evaluation options (mode, branch, complex, numbers)", + "input.options_toggle.aria_label": "Show or hide the evaluation options", "header.theme_toggle.title": "Toggle theme", "header.theme_toggle.label": "🌙 Dark", "header.export.title": "Export session as JSON", From dbd92020d068f1ec3a136343658e91d563fb8c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Gim=C3=A9nez=20Moya?= Date: Mon, 27 Jul 2026 01:33:48 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Seguridad:=20SECURITY.md=20+=20cargo=20audi?= =?UTF-8?q?t=20en=20CI=20=E2=80=94=20y=20la=20primera=20pasada=20del=20aud?= =?UTF-8?q?it=20encontr=C3=B3=202=20vulnerabilidades=20YA=20presentes,=20a?= =?UTF-8?q?rregladas=20antes=20de=20crear=20el=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EL ORDEN IMPORTÓ: antes de añadir el gate se midió el estado del árbol, y el primer `cargo audit` del proyecto encontró 2 vulnerabilidades reales en dependencias ya commiteadas: - bytes 1.11.0 → 1.12.1 (RUSTSEC-2026-0007: overflow de enteros en BytesMut::reserve) - crossbeam-epoch 0.9.18 → 0.9.20 (RUSTSEC-2026-0204: deref de puntero inválido — bajo el rayon que usa el motor) Ambas con bump de parche; workspace completo verde tras los bumps (357 suites / 12586 / 0). El gate nace VERDE, no rojo. Quedan 3 warnings informativos (bincode unmaintained, anyhow/rand unsound) que por política no gatean. SECURITY.md, honesto y a la medida del proyecto: - Canal privado (GitHub private vulnerability reporting + email de respaldo). - Alcance en positivo: ejecución/escape vía expresión o JSON de sesión manipulados, inyección en el servidor de dev, y cuelgues que SALTEN el sistema de budgets (el contrato del motor es que toda evaluación está acotada; escaparse de eso es hallazgo de seguridad, no lentitud). - Alcance en negativo explícito: lentitud dentro de budgets, resultados incorrectos (bug ordinario de primera clase, no reporte de seguridad), y DoS contra un server.py expuesto públicamente. - Modelo de confianza escrito: expresiones siempre no confiables; el import de sesión se REPRODUCE por el motor (un JSON manipulado tiene exactamente el poder de teclear lo mismo); el WASM es solo cliente; y server.py escucha en TODAS las interfaces (deliberado, lo necesita la variante Colab) — está documentado como servidor de desarrollo, no desplegable. - Best effort declarado: sin SLA ni matriz de versiones que generen deuda. CI (.github/workflows/audit.yml): rustsec/audit-check en cada cambio del árbol de dependencias y semanal contra la advisory database (los runs programados abren issue por advisory nueva — las advisories llegan sin que nadie commitee). Vulnerabilidades fallan; warnings informan. Pendiente del lado del propietario del repo: activar el private vulnerability reporting en Settings → Security para que el canal preferente exista. Co-Authored-By: Claude Fable 5 --- .github/workflows/audit.yml | 37 ++++++++++++++++++ Cargo.lock | 8 ++-- SECURITY.md | 75 +++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/audit.yml create mode 100644 SECURITY.md diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 00000000..d6206389 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,37 @@ +# Dependency security audit against the RustSec advisory database. +# +# Runs when the dependency tree changes and on a weekly schedule, so new +# advisories against ALREADY-COMMITTED dependencies still surface (on +# scheduled runs the action opens an issue for each new advisory). +# Vulnerabilities fail the job; unmaintained/unsound warnings are informational +# — the policy is written down in SECURITY.md. +name: Security audit + +on: + push: + paths: + - "**/Cargo.toml" + - "**/Cargo.lock" + - ".github/workflows/audit.yml" + pull_request: + paths: + - "**/Cargo.toml" + - "**/Cargo.lock" + - ".github/workflows/audit.yml" + schedule: + # Monday 06:30 UTC, weekly. + - cron: "30 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: rustsec/audit-check@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 17d5dced..36af11f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,9 +158,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytes" -version = "1.11.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cas_android_ffi" @@ -626,9 +626,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..0e279c18 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,75 @@ +# Security Policy + +ExpliCAS evaluates **untrusted mathematical expressions** in three deployment +shapes — client-side WebAssembly on GitHub Pages, a local development server +that forwards input to the native binary, and an Android FFI layer. That is +the attack surface this policy covers. + +This is a solo-maintained project. Reports are handled on a **best-effort +basis**: no response-time guarantees and no supported-versions matrix. The +supported target is the `main` branch and the live Pages deployment built +from it. + +## Reporting a vulnerability + +Please report security issues **privately** — not as public GitHub issues: + +1. **Preferred**: GitHub's private vulnerability reporting — *Security* tab → + *Report a vulnerability* on this repository. +2. Fallback: email the maintainer at `javiergimenezmoya@gmail.com` with + `[ExpliCAS security]` in the subject. + +Include the input (expression, session JSON, or request) that triggers the +problem and what you observed. A reproducible input is the whole report; the +project's own debugging starts from there. + +## Scope + +**In scope** — things we want reported privately: + +- Code execution, memory unsafety, or sandbox escape triggered by a crafted + expression, session-import JSON, or wire payload — in the native binary, + the WASM build, or the Android FFI. +- Injection through the development server (`web/server.py`, + `web/server_colab.py`): anything that turns an expression or HTTP request + into shell/command execution or file access beyond the intended evaluation. +- Hangs or resource exhaustion that **bypass the documented budget system** + (`docs/BUDGET_POLICY.md`): the engine's contract is that every evaluation + is bounded by rewrite/node/time budgets, so an input that escapes them is + a security-relevant robustness failure, not just slowness. +- Vulnerabilities introduced through the dependency tree that are reachable + from expression evaluation. + +**Out of scope** — better reported as regular issues: + +- Slow evaluations that stay *within* the configured budgets, or inputs the + engine honestly declines. +- Mathematically wrong results (the project treats these as its highest-class + ordinary bugs — see the soundness audits under `docs/` — but they are not + security reports). +- Denial of service against a `web/server.py` instance someone chose to + expose publicly (see the trust model below). + +## Trust model, stated plainly + +- **Expressions are always untrusted input.** The parser and engine are + expected to answer, decline, or fail a budget — never to crash the host or + execute anything. +- **Session import replays through the engine.** Imported JSON transcripts + are not trusted as state: each entry is re-evaluated by the same pipeline + as typed input, so a crafted session file has exactly the power of typing + the same expressions. +- **The WASM deployment is client-side only.** There is no server component + on GitHub Pages; the blast radius of a malicious expression there is the + visitor's own tab. +- **`web/server.py` is a development server, not a product.** Note that it + binds **all network interfaces**, not just localhost — machines on your + LAN can reach it while you develop (this is deliberate: the Colab variant + needs it). Do not deploy it publicly; nothing in it is hardened for that. + +## Dependency auditing + +`cargo audit` runs in CI (`.github/workflows/audit.yml`) on every change to +the dependency tree and on a weekly schedule against the RustSec advisory +database. Advisory-level *warnings* (unmaintained/unsound notices) are +reviewed but do not fail the build; *vulnerabilities* do.