Skip to content
Open
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- **CSP inline script elimination**: Moved inline scripts to external files;
only the import map remains inline with a CSP nonce, eliminating Safari CSP
false-positive violations.

## [0.8.8] - 2026-02-11

### Changed
Expand Down
8 changes: 3 additions & 5 deletions crates/gateway/src/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="moltis">
<link rel="apple-touch-icon" href="{{ asset_prefix }}icons/apple-touch-icon.png">
<script nonce="{{ nonce }}">!function(){var t=localStorage.getItem("moltis-theme")||"system";if(t==="system")t=matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.documentElement.setAttribute("data-theme",t);document.documentElement.style.background=t==="dark"?"#0f1115":"#fafafa";document.documentElement.style.color=t==="dark"?"#e0e0e0":"#222"}()</script>
<script src="{{ asset_prefix }}js/theme-init.js"></script>
<title>{{ share_site_name }}</title>
<meta name="description" content="{{ share_description }}">
<meta property="og:title" content="{{ share_title }}">
Expand All @@ -37,8 +37,7 @@
<link rel="icon" type="image/png" sizes="96x96" href="{{ asset_prefix }}icons/icon-96.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{ asset_prefix }}icons/icon-72.png">
<meta name="build-ts" content="{{ build_ts }}">
<script nonce="{{ nonce }}">window.__MOLTIS__={{ gon_json|safe }};</script>
<script nonce="{{ nonce }}">!function(){var g=window.__MOLTIS__;var i=g&&g.identity;var e=i&&typeof i.emoji==="string"?i.emoji.trim():"";if(!e)return;var safe=e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");var svg='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><text x="50%" y="50%" text-anchor="middle" dominant-baseline="central" font-size="52">'+safe+"</text></svg>";var href="data:image/svg+xml,"+encodeURIComponent(svg);var links=Array.from(document.querySelectorAll('link[rel="icon"]'));if(links.length===0){var fallback=document.createElement("link");fallback.rel="icon";document.head.appendChild(fallback);links.push(fallback)}for(var n=0;n<links.length;n++){var link=links[n];link.type="image/svg+xml";link.removeAttribute("sizes");link.href=href}}()</script>
<script type="application/json" id="__MOLTIS_GON__">{{ gon_json|safe }}</script>
<script nonce="{{ nonce }}" type="importmap">
{
"imports": {
Expand Down Expand Up @@ -110,7 +109,6 @@
<!-- Top bar -->
<header class="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--border)] bg-[var(--surface)] shrink-0">
<a href="{{ routes.chats }}" id="titleLink" class="text-base font-medium tracking-wide text-[var(--text-strong)] no-underline cursor-pointer"><span id="titleEmoji"></span><span id="titleName">moltis</span></a>
<script nonce="{{ nonce }}">!function(){var g=window.__MOLTIS__;if(!g||!g.identity)return;var i=g.identity;var e=document.getElementById("titleEmoji");var n=document.getElementById("titleName");if(e&&i.emoji)e.textContent=i.emoji+" ";if(n&&i.name)n.textContent=i.name;var a=(i&&i.name&&String(i.name).trim())||"moltis";document.title=a}()</script>
<span class="status-dot" id="statusDot"></span>
<span class="text-xs text-[var(--muted)]" id="statusText">disconnected</span>
<div class="flex-1"></div>
Expand Down Expand Up @@ -538,6 +536,6 @@ <h2 class="text-lg font-medium text-[var(--text)] mb-1">No LLMs Connected</h2>
</div>
</template>

<script nonce="{{ nonce }}" type="module" src="{{ asset_prefix }}js/app.js"></script>
<script type="module" src="{{ asset_prefix }}js/app.js"></script>
</body>
</html>
11 changes: 6 additions & 5 deletions crates/gateway/src/assets/js/gon.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
// ── Server-injected data (gon pattern) ────────────────────
//
// The server injects `window.__MOLTIS__ = { ... }` into every
// page <head> before any module script runs. This module
// provides typed access, runtime updates, and a refresh
// mechanism that re-fetches the data from `/api/gon`.
// The server injects a `<script type="application/json" id="__MOLTIS_GON__">`
// blob into every page <head> before any module script runs.
// This module provides typed access, runtime updates, and a
// refresh mechanism that re-fetches the data from `/api/gon`.
//
// Register listeners with `onChange(key, fn)` to react when
// a key is updated (via `set()` or `refresh()`).

var gon = window.__MOLTIS__ || {};
var gon = JSON.parse(document.getElementById("__MOLTIS_GON__")?.textContent || "{}");
window.__MOLTIS__ = gon;
var listeners = {};

export function get(key) {
Expand Down
6 changes: 4 additions & 2 deletions crates/gateway/src/assets/js/login-app.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { html } from "htm/preact";
import { render } from "preact";
import { useEffect, useState } from "preact/hooks";
import { formatLoginTitle } from "./branding.js";
import { applyIdentityFavicon, formatLoginTitle } from "./branding.js";
import { initTheme } from "./theme.js";

initTheme();

// Read identity from server-injected gon data (name for title).
var gon = window.__MOLTIS__ || {};
var gon = JSON.parse(document.getElementById("__MOLTIS_GON__")?.textContent || "{}");
window.__MOLTIS__ = gon;
var identity = gon.identity || null;

// Set page branding from identity.
document.title = formatLoginTitle(identity);
applyIdentityFavicon(identity);

async function parseLoginFailure(response) {
if (response.status === 429) {
Expand Down
7 changes: 7 additions & 0 deletions crates/gateway/src/assets/js/theme-init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
(() => {
var t = localStorage.getItem("moltis-theme") || "system";
if (t === "system") t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
document.documentElement.setAttribute("data-theme", t);
document.documentElement.style.background = t === "dark" ? "#0f1115" : "#fafafa";
document.documentElement.style.color = t === "dark" ? "#e0e0e0" : "#222";
})();
7 changes: 3 additions & 4 deletions crates/gateway/src/assets/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light">
<script nonce="{{ nonce }}">!function(){var t=localStorage.getItem("moltis-theme")||"system";if(t==="system")t=matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.documentElement.setAttribute("data-theme",t);document.documentElement.style.background=t==="dark"?"#0f1115":"#fafafa";document.documentElement.style.color=t==="dark"?"#e0e0e0":"#222"}()</script>
<script src="{{ asset_prefix }}js/theme-init.js"></script>
<title>{{ page_title }}</title>
<link rel="icon" type="image/png" sizes="96x96" href="{{ asset_prefix }}icons/icon-96.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{ asset_prefix }}icons/icon-72.png">
<meta name="build-ts" content="{{ build_ts }}">
<script nonce="{{ nonce }}">window.__MOLTIS__={{ gon_json|safe }};</script>
<script nonce="{{ nonce }}">!function(){var g=window.__MOLTIS__;var i=g&&g.identity;var e=i&&typeof i.emoji==="string"?i.emoji.trim():"";if(!e)return;var safe=e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");var svg='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><text x="50%" y="50%" text-anchor="middle" dominant-baseline="central" font-size="52">'+safe+"</text></svg>";var href="data:image/svg+xml,"+encodeURIComponent(svg);var links=Array.from(document.querySelectorAll('link[rel="icon"]'));if(links.length===0){var fallback=document.createElement("link");fallback.rel="icon";document.head.appendChild(fallback);links.push(fallback)}for(var n=0;n<links.length;n++){var link=links[n];link.type="image/svg+xml";link.removeAttribute("sizes");link.href=href}}()</script>
<script type="application/json" id="__MOLTIS_GON__">{{ gon_json|safe }}</script>
<script nonce="{{ nonce }}" type="importmap">
{
"imports": {
Expand All @@ -27,6 +26,6 @@
</head>
<body>
<div id="loginRoot" class="auth-page"></div>
<script nonce="{{ nonce }}" type="module" src="{{ asset_prefix }}js/login-app.js"></script>
<script type="module" src="{{ asset_prefix }}js/login-app.js"></script>
</body>
</html>
4 changes: 2 additions & 2 deletions crates/gateway/src/assets/onboarding.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light">
<script nonce="{{ nonce }}">!function(){var t=localStorage.getItem("moltis-theme")||"system";if(t==="system")t=matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.documentElement.setAttribute("data-theme",t);document.documentElement.style.background=t==="dark"?"#0f1115":"#fafafa";document.documentElement.style.color=t==="dark"?"#e0e0e0":"#222"}()</script>
<script src="{{ asset_prefix }}js/theme-init.js"></script>
<title>{{ page_title }}</title>
<link rel="icon" type="image/png" sizes="96x96" href="{{ asset_prefix }}icons/icon-96.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{ asset_prefix }}icons/icon-72.png">
Expand All @@ -25,6 +25,6 @@
</head>
<body>
<div id="onboardingRoot"></div>
<script nonce="{{ nonce }}" type="module" src="{{ asset_prefix }}js/onboarding-app.js"></script>
<script type="module" src="{{ asset_prefix }}js/onboarding-app.js"></script>
</body>
</html>
1 change: 1 addition & 0 deletions crates/gateway/src/assets/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
var CACHE_NAME = "moltis-v2";
var STATIC_ASSETS = [
"/manifest.json",
"/assets/js/theme-init.js",
"/assets/css/base.css",
"/assets/css/layout.css",
"/assets/css/chat.css",
Expand Down
63 changes: 47 additions & 16 deletions crates/gateway/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5711,11 +5711,14 @@ mod tests {
assert!(html.contains("/assets/v/test/js/onboarding-app.js"));
assert!(!html.contains("/assets/v/test/js/app.js"));
assert!(!html.contains("/manifest.json"));
assert!(html.contains("<script nonce=\"nonce-123\">"));
// Theme init is now an external script (no nonce).
assert!(html.contains("<script src=\"/assets/v/test/js/theme-init.js\"></script>"));
// Import map still requires a nonce.
assert!(html.contains("<script nonce=\"nonce-123\" type=\"importmap\">"));
assert!(html.contains(
"<script nonce=\"nonce-123\" type=\"module\" src=\"/assets/v/test/js/onboarding-app.js\">"
));
// External module script does NOT need a nonce.
assert!(
html.contains("<script type=\"module\" src=\"/assets/v/test/js/onboarding-app.js\">")
);
}

#[cfg(feature = "web-ui")]
Expand Down Expand Up @@ -5821,10 +5824,22 @@ mod tests {
assert!(html.contains(
"<link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/assets/v/test/icons/icon-72.png\">"
));
assert!(html.contains("<script nonce=\"nonce-abc\">window.__MOLTIS__={\"identity\":{\"name\":\"moltis\"}};</script>"));
// Gon data is now a non-executable JSON blob.
assert!(html.contains(
"<script nonce=\"nonce-abc\" type=\"module\" src=\"/assets/v/test/js/login-app.js\"></script>"
"<script type=\"application/json\" id=\"__MOLTIS_GON__\">{\"identity\":{\"name\":\"moltis\"}}</script>"
));
// External module script does NOT need a nonce.
assert!(
html.contains(
"<script type=\"module\" src=\"/assets/v/test/js/login-app.js\"></script>"
)
);
// Theme init is external.
assert!(html.contains("<script src=\"/assets/v/test/js/theme-init.js\"></script>"));
// Inline favicon script was removed.
assert!(!html.contains("var svg="));
// Import map still has nonce.
assert!(html.contains("<script nonce=\"nonce-abc\" type=\"importmap\">"));
}

#[cfg(feature = "web-ui")]
Expand Down Expand Up @@ -6136,14 +6151,21 @@ mod tests {
Ok(html) => html,
Err(e) => panic!("failed to render index template: {e}"),
};
assert!(index_html.contains(&format!("<script nonce=\"{nonce}\">!function()")));
assert!(index_html.contains(&format!(
"<script nonce=\"{nonce}\">window.__MOLTIS__={{}};</script>"
)));
// Theme init is now an external script (no nonce needed, allowed by 'self').
assert!(index_html.contains("<script src=\"/assets/v/test/js/theme-init.js\"></script>"));
// Gon data is a non-executable JSON blob (no nonce needed).
assert!(index_html.contains("<script type=\"application/json\" id=\"__MOLTIS_GON__\">"));
// Inline favicon script was removed (handled by app.js).
assert!(!index_html.contains("var svg="));
// Import map still requires a nonce.
assert!(index_html.contains(&format!("<script nonce=\"{nonce}\" type=\"importmap\">")));
assert!(index_html.contains(&format!(
"<script nonce=\"{nonce}\" type=\"module\" src=\"/assets/v/test/js/app.js\"></script>"
)));
// External module script does NOT need a nonce (allowed by 'self').
assert!(
index_html
.contains("<script type=\"module\" src=\"/assets/v/test/js/app.js\"></script>")
);
// Inline title-update script was removed (handled by app.js).
assert!(!index_html.contains("document.title=a}()"));

let onboarding_template = OnboardingHtmlTemplate {
build_ts: "dev",
Expand All @@ -6155,9 +6177,18 @@ mod tests {
Ok(html) => html,
Err(e) => panic!("failed to render onboarding template: {e}"),
};
assert!(onboarding_html.contains(&format!(
"<script nonce=\"{nonce}\" type=\"module\" src=\"/assets/v/test/js/onboarding-app.js\"></script>"
)));
// Onboarding: external module script does NOT need a nonce.
assert!(onboarding_html.contains(
"<script type=\"module\" src=\"/assets/v/test/js/onboarding-app.js\"></script>"
));
// Onboarding: theme init is external.
assert!(
onboarding_html.contains("<script src=\"/assets/v/test/js/theme-init.js\"></script>")
);
// Onboarding: import map still has nonce.
assert!(
onboarding_html.contains(&format!("<script nonce=\"{nonce}\" type=\"importmap\">"))
);
}

#[cfg(feature = "web-ui")]
Expand Down
Loading