cti-transmute v1.5 - Codebase architecture improvement, security review fixes and public conversion API
LatestCTI-Transmute v1.5
This release is dominated by three things: 16 security fixes from an external review and an internal one, a first-class public API for the conversion catalogue, and the architecture work that made both possible - a real test suite (0 to 516 tests), CI on every push, and a clean separation between the conversion engine, the service layer, and persistence.
All users of 1.4 and earlier should upgrade.
It also carries a database schema rename and a set of HTTP method changes, so read the Upgrading section before deploying.
Security
This release fixes 16 security issues. Every one has a fix and a dedicated regression test, and every one is remediated in this release.
| ID | Advisory | CWE | Version affected |
|---|---|---|---|
| GCVE-1-2026-20038 - CVE-2026-69078 | Unauthenticated HTML injection in the PDF export enables local file read and SSRF | CWE-918, CWE-73 | 1.4 |
| GCVE-1-2026-20062 - CVE-2026-72751 | Unauthenticated stored XSS in the conversion graph view | CWE-79 | 1.2 - 1.4 |
| GCVE-1-2026-20090 - CVE-2026-72759 | Private conversion content disclosed via history entries of deleted conversions | CWE-639 | 1.0 - 1.4 |
| GCVE-1-2026-20037 - CVE-2026-72760 | User e-mail addresses disclosed through the account following list | CWE-359 | 1.3 - 1.4 |
| GCVE-1-2026-20160 - CVE-2026-73140 | Private evaluation comments disclosed through unauthenticated report exports | CWE-862, CWE-200 | 1.4 |
| GCVE-1-2026-20064 - CVE-2026-69079 | Unauthenticated denial of service via unbounded days parameter on the activity timeline |
CWE-1284, CWE-400 | 1.4 |
| GCVE-1-2026-20170 - CVE-2026-73155 | Missing authorization allows reacting to comments the user cannot see | CWE-862 | 1.3 - 1.4 |
| GCVE-1-2026-20179 - CVE-2026-73156 | Unauthenticated stored XSS in the conversion sunburst tooltips | CWE-79 | 1.4 |
| GCVE-1-2026-20082 - CVE-2026-73157 | Stored XSS from remote MISP content in the event browser | CWE-79 | 1.3 - 1.4 |
| GCVE-1-2026-20074 - CVE-2026-73158 | Stored XSS through saved graph configurations | CWE-79, CWE-20 | 1.3 - 1.4 |
| GCVE-1-2026-20164 - CVE-2026-73159 | Stored XSS via the tag icon field executes in an admin session | CWE-79 | 1.3 - 1.4 |
| GCVE-1-2026-20135 - CVE-2026-73160 | Unauthenticated SSRF via user-supplied MISP URL on the fetch routes | CWE-918, CWE-306 | 1.3 - 1.4 |
| GCVE-1-2026-20177 - CVE-2026-73161 | Stored XSS via the conversion-table search highlight | CWE-79 | 1.4 |
| GCVE-1-2026-20111 - CVE-2026-69082 | CSRF-driven admin user deletion via GET on the account delete route | CWE-352, CWE-650 | 1.0 - 1.4 |
| GCVE-1-2026-20115 - CVE-2026-73162 | CSRF on GET: follow and notification routes mutate state over GET | CWE-352, CWE-650 | 1.3 - 1.4 |
| GCVE-1-2026-20109 - CVE-2026-71502 | Unauthenticated stored client-side template injection executes JavaScript in any viewer's session | CWE-1336, CWE-79 | 1.0 - 1.4 |
Three further findings were fixed without a separate advisory: the missing security headers and unhardened session cookie (a hardening gap rather than an independently exploitable weakness - it amplified the XSS findings, and is covered below), an unsafe innerHTML error rendering in the graph configuration modal, and the same pattern in the parameter-load error notice on the MISP→STIX, STIX→MISP and refresh pages. Neither innerHTML case has an exploitable path - the interpolated value is a local error string in both - so both were fixed as code hygiene, the notices now being set as DOM text through one shared helper.
What changed as a result:
- Content-Security-Policy with a per-request nonce. Inline
<script>blocks only run when stamped with the request's nonce, so injected markup (<img onerror=...>,<script>) raises a CSP violation instead of executing. Every inlineon*=handler in the templates was rewritten toaddEventListenerwiring to make this possible.'unsafe-eval'remains, required by Vue's runtime template compiler. - Server-rendered values can no longer be executed as front-end template code. Vue compiles the page region it mounts over, so a conversion name or user name containing its
[[ … ]]expression delimiters used to run as JavaScript in every later viewer's session - the one XSS the nonce CSP could not stop, because it travels down the'unsafe-eval'Vue itself needs. The delimiters are now neutralised in everything the server renders, applied once at the template-render boundary rather than page by page, so a template written the ordinary way is safe. Names display exactly as typed. On top of that, every region that carried a server-rendered user value into a mounted app - the flash messages, the public profile, the account page, the registration form, and the conversion name on the detail, refresh and version-comparison pages - is now marked so Vue skips compiling it, and the set of mounted regions is recorded and linted: the suite fails if a new template writes an unprotected server-rendered value into one. - Full security header set:
X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: strict-origin-when-cross-origin,Permissions-Policy, and HSTS (only over TLS, so a plain-HTTP dev instance never pins browsers to HTTPS). - Hardened session cookies:
SecureandSameSite=Laxon both the session and remember-me cookies. See Upgrading -Securenow defaults to on. - The PDF renderer no longer touches the filesystem or the network. A custom WeasyPrint
url_fetcherrefusesfile://and non-allowlisted hosts. - Untrusted text is escaped at every sink that was found reflecting it: the graph property panel, Sunburst tooltips, the remote-MISP event browser rows, the conversion-table search highlight, and tag icons.
- The graph library was hardened upstream, and the graph view now rests on that. Pivotick 1.5.0 - reviewed by Jeroen Pinoy, who also reviewed this service - inserts everything it renders from graph data as text, sanitises node
svgIconmarkup, allowlists the URL schemes a node image may load, and bounds its recursive walks over caller-supplied data. cti-transmute stopped pre-escaping node and edge labels as a result: against 1.4.0'sinnerHTMLrenderer that escaping was the fix, but in front of a text-inserting renderer it double-escaped ordinary values, so an event title carrying&or"would have reached the viewer as&/". Property-panel cells are still handed over as DOM elements rather than strings, so they cannot be parsed even if that resolution changes again, and the suite asserts that the vendored Pivotick is 1.5.0 or newer, so the guarantee the labels now rest on cannot be lost to a downgrade. - Saved graph configurations are no longer trusted. Every configuration patch is schema-filtered on both ends: the client accepts exactly
shape/color/sizewith validated values, and the server re-filters stored rows when serving them, so pre-existing hostile rows are neutralised too. - Visibility rules applied consistently: history details, evaluation report exports, emoji reactions, and the version-difference page all now follow the same shared
can_seerule as the rest of the read routes. - The remote-MISP fetch routes require a login, and their target URL is vetted against the resolved IP, not just against internal addresses given as IP literals.
- Routes that mutate state no longer answer
GET. Twelve routes across the account and conversion features now requirePOSTorDELETEwith a CSRF token, so none of them is reachable from an attacker page's<img src=...>. See Upgrading for the full list.
New features
Public read-only API for the conversion catalogue
The public API previously exposed only the conversion action. The catalogue was reachable only through the web UI's own session-authenticated routes. Three new endpoints close that gap:
| Endpoint | Description |
|---|---|
GET /api/conversions |
Paginated catalogue, access-scoped to the caller. Supports page, q, search_scope, exact_match, tags, date_from/date_to, sort, visibility, mine, type. |
GET /api/conversions/<id> |
One conversion, including its input and output. |
GET /api/conversions/<id>/history |
A conversion's accepted re-run history, oldest first. |
Access parity with the browser is exact: anonymous callers see public conversions only, and a conversion the caller cannot see returns 404 rather than 403, so a private record's existence is never confirmed to a stranger.
API key authentication and opt-in persistence
X-API-KEYheader on the public API. Absent means anonymous (matching a logged-out browser); a key matching a user makes that user the actor; a key matching nobody is a403rather than a silent fall-through to anonymous.?persist=trueon the conversion endpoints saves the result to the catalogue as a Conversion, so an API caller gets the same catalogue entry a web submission does. The response envelope carries the record'surl,id, anduuid.
Generated parameter surface
Converter parameters used to be re-declared by hand at every surface: a reqparse parser for the API, a WTForm for the web page, and some choices hardcoded a third time in the template. Now each Converter's parameter schema is the single source, and every surface is generated from it:
GET /api/convert/listis built from the Converter registry and publishes each converter's JSON Schema plus a human-readable description.- The MISP→STIX, STIX→MISP, refresh, and
/listpages all render their controls from the published schema, and submit as JSON. - Pydantic validates parameters from the query string;
reqparseand the per-converter WTForms are retired. - Boolean flags no longer cause a
500, and parameter errors return a structured400the web UI surfaces directly.
Parameters recorded per conversion
Each conversion now stores the typed parameters it ran with, and the free-form conversion_type string is replaced by explicit source_format / target_format slugs. Re-running a conversion prefills the form from the stored parameters, and accepting a pending re-run adopts its parameters too. Records created before this release show "Parameters not recorded" rather than fabricated values.
Accounts and administration
- Public user profiles and a follow system, with the follower and following lists on the account page. (Thanks @ecrou-exact)
manage create_admincreates an emergency administrator account with a random password. (Thanks @ecrou-exact)manage psqlopens an interactivepsqlsession against the project database. (Thanks @ecrou-exact)manage startapplies pending database migrations automatically, handling all three bootstrap states: an empty database, acreate_all()database with no alembic version, and a normally versioned one..env.exampleandpython-dotenvfor environment configuration. (Thanks @ecrou-exact)
Changes and improvements
Vocabulary: Convert is the verb, Conversion is the record
The saved catalogue record is a Conversion; Converter is the engine that performs one direction. "Convert" survives only as the verb and in the public API URLs. This swept through the models, tables, URLs, templates, forms, activity log, notifications, and user-facing copy. A new CONTEXT.md records the glossary.
Old /convert/... URLs 301 to their /conversions/... equivalents, preserving sub-path and query string, so existing links and bookmarks survive.
Continuous integration
- CI on every push and pull request: Ruff lint, then pytest across Python 3.10, 3.11, 3.12, and 3.13. Node and the WeasyPrint native libraries are installed in the job so the JS-check and PDF-render tests actually run instead of skipping silently.
- Release workflow: pushing a
v*tag prepares a GitHub release automatically, as a draft so the notes can be reviewed before anyone sees them. - Ruff added as the linter.
Test suite
From no tests at v1.4 to 516, covering the converters, the API, the service layer, the repositories, and the web routes. Every security fix in this release ships with a regression test that pins the behaviour, and two of them are enforced structurally rather than by example: a lint over the template tree for server-rendered values inside a Vue-mounted region, and source checks over the JavaScript sinks. An optional live MISP round-trip test can be enabled with a local config file.
Architecture
- The conversion engine is a registry of Converters, each declaring its source and target formats, its parameter model, its output format, and a description. Adding a format is a registry entry, not an edit across every surface.
website/lib/holds the service-layer use-cases (submit_conversion, the refresh/accept/reject trio,add_comment,report_conversion,bulk_action,push_to_misp) and shared authorisation predicates, so the web routes are thin adapters and the API is not a degraded sibling of the web UI.website/repos/owns aggregate persistence (conversions, comments, reports), including listing and search, so the feature modules no longer hand-roll queries.- The web views call the conversion use-case directly; the old HTTP loopback where the web UI called its own API is gone.
- A thin remote-MISP request helper owns the shared
requestscascade with typed exceptions, and the MISP push payload builder is pure (no Flask, no DB, no HTTP). vendor/is renamed tosubmodules/, and the submodules are treated as upstream checkouts rather than vendored code. MISP Galaxy and MISP Taxonomies are bumped to their current upstream versions, so the tag catalogue ships up to date.
Interface
- Graph view: the Pivotick submodule goes from 0.0.1 to 1.5.0 (409 upstream commits), bringing a faceted multi-select sidebar, a type-aware single-node properties panel, a filterable connection-stat facet, image node rendering with
imageFitmodes and graceful fallbacks, theme-aware scrollbars, note cards, opt-in auto-fit on cluster expand/collapse, and a large number of renderer fixes. - The graph's controls are rearranged, because 1.5.0 replaces the classic control bar and toolbar with a mode-driven layout: a left rail for Select / Create / View, tool panels that swap with the active mode, and a View flyout that gathers the layout, grid and physics controls (the physics knobs now carry presets). Multi-selection actions moved into a bulk-action row on the sidebar, and per-node actions into the right-click menu. Nothing was removed from the graph view itself - the same controls are in new places.
- The two STIX conversion pages are aligned on the same form styling and input-first field order.
- Long filenames are ellipsized in the upload drop zone.
- Timestamps serialize as ISO-8601 UTC and render in the viewer's timezone. The previous wire format was an offset-less wall-clock string, so every date was off by the viewer's UTC offset.
- JSON is pretty-printed when comparing conversion versions, so diffs are readable.
- Static assets are cache-busted by modification time.
v-cloakon the Vue templates removes the flash of unrendered markup. (Thanks @ecrou-exact)- The displayed version comes from the package metadata, so the footer and the API version can no longer drift from the real one.
Documentation
CONTEXT.md, a domain glossary of the canonical terms.- The in-app documentation page had drifted: the API facts (
X-API-KEY,?persist=, no Bearer token), the model tables, the route list, and the HTTP method badges are corrected and now pinned by tests. - The MISP instance import and push features are documented (they shipped undocumented), and the read-only conversion API is documented.
- README screenshots and the OASIS STIX documentation link updated.
Bug fixes
- The version-difference page gated access backwards: a public conversion demanded owner-or-admin while a private one rendered unconditionally, exposing both output versions to anyone with the URL.
- The trash listed live conversions instead of soft-deleted ones.
- "My comments" listed only the user's deleted comments, and a fresh soft-delete reappeared on the next refresh.
- The private visibility filter returned no private rows.
- The conversion author lookup crashed on records with no owner (anonymous API submissions).
- The MISP→STIX view's Submit button was broken.
- MISP input handling defers to
misp-stixfor validity instead of second-guessing it, and non-dict JSON no longer breaks name extraction. - The conversion name column was too short for real names.
- Shared record URLs pointed at the
301-shimmed/convert/path instead of/conversions/. /conversions/<id>returned by the API now resolves to the detail page.- File upload validation on both conversion pages properly checks JSON format and UTF-8 encoding. (Thanks @ecrou-exact)
- Node hover tooltips no longer die permanently after a second node is selected.
- A circular import in the engine's default configuration.
- Typo in the successful registration message. (Thanks @Wachizungu)
Upgrading
Database migrations
Four new migrations. manage update and manage start run them automatically; otherwise run flask db upgrade. Back up first - two of them rewrite data.
converttables renamed toconversion. Six tables and everyconvert_idforeign key column. These are in-place renames, so data is preserved.- Parameters persisted,
conversion_typedropped.source_format,target_format, andparamsare added toconversion;source_format/target_formatare backfilled from the oldconversion_typebefore the column is dropped.conversion_typesurvives as a derived property on the model. Pre-existing rows getparams = NULL. - Stored
convertvalues rewritten toconversionin the activity log and notifications. Idempotent, with an exact inverse downgrade. - The
tags_vendor_pull_startedactivity-log event renamed totags_submodule_pull_started.
Session cookies now default to Secure
SESSION_COOKIE_SECURE defaults to true. Browsers exempt localhost, but an instance reached over plain HTTP by IP or hostname will not be able to log in. Such deployments must opt out explicitly:
SESSION_COOKIE_SECURE=false
in .env. Deployments behind TLS need no change.
Content-Security-Policy
The CSP is strict and allowlists exactly the external origins the app uses (cdnjs, jsdelivr, Google Fonts, img.youtube.com for the demo thumbnail). Any local customisation that adds an external script, style, font, or image origin, or that introduces an inline <script> or an inline on*= handler, will be blocked and needs the CSP template in website/web/__init__.py updated. Inline scripts must carry nonce="{{ csp_nonce }}".
HTTP methods changed
These routes no longer accept GET. Any external script, bookmark, or integration calling them must send the new verb with a CSRF token:
| Route | Was | Now |
|---|---|---|
/conversions/delete_item |
GET, POST, DELETE |
POST, DELETE |
/conversions/edit_public |
GET |
POST |
/conversions/regenerate_share_key |
GET |
POST |
/conversions/delete_comment |
GET |
DELETE |
/conversions/toggle_comment_private |
GET |
POST |
/conversions/admin/review_report |
GET |
POST |
/conversions/admin/delete_report |
GET |
DELETE |
/account/follow |
GET |
POST |
/account/delete_notification |
GET |
DELETE |
/account/mark_notification_read |
GET |
POST |
/account/mark_all_read |
GET |
POST |
/account/delete/<id> |
GET, POST |
POST |
Other behaviour changes to be aware of
/account/get_followingno longer returns theemailfield (email enumeration fix). Any consumer relying on it must be updated./evaluate/activity_timeline?days=is clamped to 1095 (3 years). Larger values are silently capped instead of doing unbounded work.- A value containing
[[or]]renders with an invisible word joiner between the brackets in HTML pages, which is what stops Vue from compiling it. Stored data and API responses are untouched, but text copied out of a rendered page can carry a zero-width character. - Old
/convert/...URLs301to/conversions/.... Clients that do not follow redirects need their URLs updated. - The submodule directory moved from
vendor/tosubmodules/. After pulling, rungit submodule update --init --recursive; the oldvendor/directory is left behind on existing checkouts and can be removed. Pivotick's compiled bundle is committed underwebsite/web/static/pivotick/, so the graph works without checking the submodule out or running a Node build;bin/build_assets.pyis only for rebuilding it from source. Only the IIFE build the page loads is committed now -pivotick.iife.jsandpivotick.css; the ES build is no longer shipped, since its entry point merely re-exported a hashed chunk that was never committed alongside it. - The pre-submodules Pivotick copies are deleted:
website/web/static/js/pivotick.umd.jsandwebsite/web/static/js/SimulationWorker.js, left behind by the move tosubmodules/, are gone. Nothing in the app referenced them, but a local customisation or template loading either path directly must load/static/pivotick/pivotick.iife.jsinstead. - The graph view's controls are in different places (see Interface). Saved graph configurations still load - the
full/minimalmode and theshape/color/sizestyles are unaffected - but any local customisation of the graph chrome, or documentation and screenshots showing the old control bar and toolbar, needs revisiting. - WeasyPrint native libraries are needed for the evaluation PDF export. The list lives in
bin/weasyprint-system-deps.txtand the container installers and CI both read it.
Contributors
- Christian Studer (@chrisr3d)
- Théo Geffé (@ecrou-exact) - public profiles and followers, emergency admin command,
psqlcommand,
dotenv integration, upload validation, interface polish - Jeroen Pinoy (@Wachizungu) - security review, and #5
Thanks to Jeroen Pinoy for the thorough security review, and to Sami Mokaddem for Pivotick.
Full Changelog: v1.4...v1.5