release: v1.6.1 link sanitization and backlog updates

This commit is contained in:
2026-04-27 16:25:35 -07:00
parent d9eba53926
commit 28aa40c42a
5 changed files with 60 additions and 13 deletions

21
app.py
View File

@@ -164,6 +164,17 @@ def format_direct_answer(question: str, results: list[dict]) -> str:
return "\n".join(lines).strip()
def sanitize_outbound_url(url: str) -> str:
"""Allow only absolute http/https URLs for outbound links shown in UI."""
if not url:
return ""
candidate = url.strip()
parsed = urlparse(candidate)
if parsed.scheme.lower() in {"http", "https"} and parsed.netloc:
return candidate
return ""
# --- Default Profile ---
DEFAULT_PROFILE = """You are a coding companion running locally on a machine called "jarvis".
@@ -571,7 +582,7 @@ async def query_searxng(query: str, max_results: int = 5) -> list[dict]:
return [
{
"title": "Current Weather",
"url": f"https://wttr.in/{location}",
"url": sanitize_outbound_url(f"https://wttr.in/{location}"),
"content": resp.text.strip(),
}
]
@@ -603,9 +614,11 @@ async def query_searxng(query: str, max_results: int = 5) -> list[dict]:
results.append(
{
"title": box.get("infobox", "Info"),
"url": box.get("urls", [{}])[0].get("url", "")
"url": sanitize_outbound_url(
box.get("urls", [{}])[0].get("url", "")
if box.get("urls")
else "",
else ""
),
"content": content,
}
)
@@ -613,7 +626,7 @@ async def query_searxng(query: str, max_results: int = 5) -> list[dict]:
results.append(
{
"title": r.get("title", ""),
"url": r.get("url", ""),
"url": sanitize_outbound_url(r.get("url", "")),
"content": r.get("content", ""),
}
)

View File

@@ -13,9 +13,9 @@ Total identified items: 26
- P3: Nice-to-have polish.
## Top 10 (Urgency Order)
1. [P0] Add authentication/authorization for all write and admin endpoints.
2. [P0] Add CSRF/origin protection for browser-initiated state-changing requests.
3. [P0] Block unsafe URL schemes in rendered search-result links (e.g., javascript:).
1. [P0][DONE] Add authentication/authorization for all write and admin endpoints.
2. [P0][DONE] Add CSRF/origin protection for browser-initiated state-changing requests.
3. [P0][DONE] Block unsafe URL schemes in rendered search-result links (e.g., javascript:).
4. [P0] Add rate limiting and request body size limits for chat/search/profile APIs.
5. [P1] Restrict settings updates to an allowlist of valid keys.
6. [P1] Add pagination + hard caps on list endpoints (memories, conversations, message history).

View File

@@ -44,9 +44,9 @@ Total identified items: 26
Top 10 (brief):
1. P0: Add auth for write/admin endpoints
2. P0: Add CSRF/origin protection for state-changing requests
3. P0: Block unsafe URL schemes in rendered links
1. P0 [DONE]: Add auth for write/admin endpoints
2. P0 [DONE]: Add CSRF/origin protection for state-changing requests
3. P0 [DONE]: Block unsafe URL schemes in rendered links
4. P0: Add rate limiting and request size limits
5. P1: Restrict `/api/settings` updates to allowlisted keys
6. P1: Add pagination + hard caps for list APIs
@@ -57,7 +57,7 @@ Top 10 (brief):
Item 1 executive summary: keep guest mode for conversational chat, require 4-digit admin PIN for advanced/destructive actions, and enforce local/LAN-only backend policy by default.
Implementation status: complete (guest session by default + admin unlock + admin-only write enforcement + origin checks + audit logging + capability tests).
Implementation status: complete (guest session by default + admin unlock + admin-only write enforcement + origin checks + safe-link sanitization + audit logging + capability tests).
## TODO

View File

@@ -970,7 +970,13 @@ async function sendSearch() {
if (data.raw_results) {
let rawHtml = '<details class="raw-results"><summary>🔍 View raw search results (' + data.raw_results.length + ')</summary><ul>';
data.raw_results.forEach(r => {
rawHtml += `<li><a href="${escapeHtml(r.url)}" target="_blank" rel="noopener">${escapeHtml(r.title)}</a>`;
const safeUrl = sanitizeUrl(r.url || '');
rawHtml += '<li>';
if (safeUrl) {
rawHtml += `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener">${escapeHtml(r.title)}</a>`;
} else {
rawHtml += `<span>${escapeHtml(r.title)}</span>`;
}
if (r.content) rawHtml += `<small>${escapeHtml(r.content)}</small>`;
rawHtml += '</li>';
});
@@ -1102,6 +1108,17 @@ function renderMarkdown(text) {
return h;
}
function sanitizeUrl(url) {
if (!url) return '';
try {
const parsed = new URL(url, window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return parsed.href;
} catch (e) {
return '';
}
return '';
}
function addCopyButtons(msgDiv) {
msgDiv.querySelectorAll('pre').forEach(pre => {
if (pre.querySelector('.copy-btn')) return;

View File

@@ -0,0 +1,17 @@
import app as app_module
def test_sanitize_outbound_url_allows_http_https():
assert app_module.sanitize_outbound_url("https://example.com/path") == "https://example.com/path"
assert app_module.sanitize_outbound_url("http://example.com") == "http://example.com"
def test_sanitize_outbound_url_blocks_unsafe_schemes():
assert app_module.sanitize_outbound_url("javascript:alert(1)") == ""
assert app_module.sanitize_outbound_url("data:text/html,evil") == ""
assert app_module.sanitize_outbound_url("file:///etc/passwd") == ""
def test_sanitize_outbound_url_blocks_relative_and_empty():
assert app_module.sanitize_outbound_url("/relative/path") == ""
assert app_module.sanitize_outbound_url("") == ""