From ff09d6ed60998e31f8baf0846b9bdd31282979ed Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sat, 27 Jun 2026 15:07:55 +0900 Subject: CSPによるインラインJavascriptの実行ブロックの不具合を修正。 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ollama_web/static/i18n.js | 25 +++++++++++++-- src/ollama_web/templates/index.html | 6 ++-- src/ollama_web/templates/login.html | 4 +-- tests/test_i18n_rendering.py | 61 +++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 10 deletions(-) diff --git a/src/ollama_web/static/i18n.js b/src/ollama_web/static/i18n.js index 985b452..7f8d013 100644 --- a/src/ollama_web/static/i18n.js +++ b/src/ollama_web/static/i18n.js @@ -1,17 +1,35 @@ /** Small i18n helper for the browser. * - * The server injects ``window.I18N`` as a nested message object. This module - * exposes ``t(key, fallback)`` to resolve dot-separated keys safely. + * The server stores the nested message object as JSON in a non-executable + * meta element. This module parses it and exposes ``t(key, fallback)`` to + * resolve dot-separated keys safely without requiring inline JavaScript. */ (function () { "use strict"; + const dataEl = document.querySelector('meta[name="ollama-web-i18n"]'); + let messages = {}; + + if (!dataEl) { + console.error('i18n data element "ollama-web-i18n" was not found'); + } else { + try { + const parsed = JSON.parse(dataEl.getAttribute("content") || ""); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new TypeError("i18n data must be a JSON object"); + } + messages = parsed; + } catch (err) { + console.error("failed to parse i18n data", err); + } + } + function t(key, fallback) { if (typeof key !== "string") { return fallback !== undefined ? fallback : String(key); } const parts = key.split("."); - let current = window.I18N; + let current = messages; for (const part of parts) { if (current == null || typeof current !== "object" || !(part in current)) { return fallback !== undefined ? fallback : key; @@ -24,5 +42,6 @@ return fallback !== undefined ? fallback : key; } + window.I18N = messages; window.t = t; })(); diff --git a/src/ollama_web/templates/index.html b/src/ollama_web/templates/index.html index 11457e8..df877fa 100644 --- a/src/ollama_web/templates/index.html +++ b/src/ollama_web/templates/index.html @@ -11,9 +11,7 @@ - + @@ -89,4 +87,4 @@ - \ No newline at end of file + diff --git a/src/ollama_web/templates/login.html b/src/ollama_web/templates/login.html index 128c842..263afe7 100644 --- a/src/ollama_web/templates/login.html +++ b/src/ollama_web/templates/login.html @@ -5,9 +5,7 @@ ollama-web login - + diff --git a/tests/test_i18n_rendering.py b/tests/test_i18n_rendering.py index 95015d5..0c37aad 100644 --- a/tests/test_i18n_rendering.py +++ b/tests/test_i18n_rendering.py @@ -2,7 +2,10 @@ from __future__ import annotations +import json + import pytest +from bs4 import BeautifulSoup from starlette.testclient import TestClient from ollama_web.app import create_app @@ -30,6 +33,17 @@ def _render_index() -> str: return response.text +def _render_pages(): + """Render login and authenticated index pages with their response headers.""" + app = create_app() + with TestClient(app) as client: + login_page = client.get("/login") + response = client.post("/api/auth/login", json={"pin": settings.pin}) + assert response.status_code == 200 + index_page = client.get("/") + return login_page, index_page + + def test_english_language_consistency(reset_language): """When settings.language='en', all UI text including MCP must be English.""" settings.language = "en" @@ -70,3 +84,50 @@ def test_t_helper_respects_language_override(): assert t("common.send", lang="ja") == "送信" assert t("mcp.server_settings", lang="en") == "MCP Server Settings" assert t("mcp.server_settings", lang="ja") == "MCPサーバー設定" + + +@pytest.mark.parametrize( + ("lang", "delete_text", "server_settings", "login_title"), + [ + ("en", "Delete", "MCP Server Settings", "Login"), + ("ja", "削除", "MCPサーバー設定", "ログイン"), + ], +) +def test_browser_messages_are_embedded_as_json_data( + reset_language, + lang: str, + delete_text: str, + server_settings: str, + login_title: str, +): + """Both pages expose parseable localized data without executable inline scripts.""" + settings.language = lang + login_page, index_page = _render_pages() + + for response in (login_page, index_page): + soup = BeautifulSoup(response.text, "html.parser") + data_element = soup.select_one('meta[name="ollama-web-i18n"]') + assert data_element is not None + + messages = json.loads(data_element["content"]) + assert messages["html"]["lang"] == lang + assert messages["common"]["delete"] == delete_text + assert messages["mcp"]["server_settings"] == server_settings + assert messages["login"]["title"] == login_title + + inline_scripts = [script for script in soup.find_all("script") if not script.get("src")] + assert not inline_scripts + + +def test_i18n_pages_keep_strict_script_csp(reset_language): + """The localization transport must not require weakening script-src.""" + login_page, index_page = _render_pages() + + for response in (login_page, index_page): + csp = response.headers["content-security-policy"] + script_directive = next( + directive.strip() + for directive in csp.split(";") + if directive.strip().startswith("script-src") + ) + assert script_directive == "script-src 'self'" -- cgit v1.2.3