From 364af50a7c68d5345d3e6ea94e70d1e1c63dede9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 26 Jul 2026 16:21:58 +0200 Subject: [PATCH] fix security 1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/assets.rs | 1 + static/js/home.js | 9 +++++---- static/js/markdown.js | 20 ++++++++++++++------ static/js/note-files.js | 6 ++++-- static/js/public.js | 2 +- static/js/security.js | 26 ++++++++++++++++++++++++++ static/js/workspace.js | 7 ++++--- 9 files changed, 57 insertions(+), 18 deletions(-) create mode 100644 static/js/security.js diff --git a/Cargo.lock b/Cargo.lock index 6220bee..1824907 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.0.41" +version = "0.0.42" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index ffbc61b..7be527f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.0.41" +version = "0.0.42" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/src/assets.rs b/src/assets.rs index 843521e..ad9cd3d 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -21,6 +21,7 @@ const MODULES: &[&str] = &[ "session", "socket", "url-state", + "security", ]; pub fn render_html( diff --git a/static/js/home.js b/static/js/home.js index 6a07e25..596064d 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -5,6 +5,7 @@ import { bindIdentityDialog, handleAccountConfirmationToken, handleResetToken, l import { getAuthToken, setAccessToken } from "@rustpad/session"; import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; +import { safeAppUrl } from "@rustpad/security"; function slugify(value, fallback) { return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback; @@ -52,7 +53,7 @@ document.querySelector("#pad-form").addEventListener("submit", async (event) => if (password.value) payload.password = password.value; const result = await api("/api/pads", { method: "POST", headers: authHeaders(), body: JSON.stringify(payload) }); if (password.value) { const grant = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "pad", slug: result.slug, password: password.value }) }); setAccessToken("pad", result.slug, grant.access_token); } - window.location.assign(`${result.url}?view=split&mode=markdown`); + window.location.assign(safeAppUrl(`${result.url}?view=split&mode=markdown`)); } catch (requestError) { error.textContent = requestError.message; } finally { @@ -73,7 +74,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even if (password.value) payload.password = password.value; const result = await api("/api/workspaces", { method: "POST", headers: authHeaders(), body: JSON.stringify(payload) }); if (password.value) { const grant = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "workspace", slug: result.slug, password: password.value }) }); setAccessToken("workspace", result.slug, grant.access_token); } - window.location.assign(result.url); + window.location.assign(safeAppUrl(result.url)); } catch (requestError) { error.textContent = requestError.message; } finally { @@ -110,7 +111,7 @@ async function loadResources() { const sharedLabel = !item.owned ? `Shared by ${escapeHtml(item.shared_by || "another user")}` : ""; const permissionLabel = item.permission === "rw" ? "Read and write" : "Read only"; row.classList.toggle("resource-row--shared", !Boolean(item.owned)); - row.innerHTML = `
${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}
${item.owned ? `` : ""}
`; + row.innerHTML = `
${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}
${item.owned ? `` : ""}
`; const inline = row.querySelector("[data-inline]"); const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; }; @@ -210,7 +211,7 @@ async function loadResources() { row.querySelector("[data-delete]")?.addEventListener("click", () => { inline.hidden = false; - inline.innerHTML = `

Delete “${item.title}” permanently?

`; + inline.innerHTML = `

Delete “${escapeHtml(item.title)}” permanently?

`; inline.querySelector("[data-cancel]").addEventListener("click", closeInline); inline.querySelector("[data-confirm-delete]").addEventListener("click", async event => { event.currentTarget.disabled = true; diff --git a/static/js/markdown.js b/static/js/markdown.js index c3e6cf8..c33fc32 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -5,9 +5,17 @@ function escapeHtml(value) { } function safeUrl(value) { - const url = String(value).trim(); - if (/^(https?:\/\/|mailto:|\/|\.\/|\.\.\/|#)/i.test(url)) return escapeHtml(url); - return "#"; + const raw = String(value || "").trim(); + if (!raw || raw.startsWith("//")) return "#"; + if (raw.startsWith("#")) return escapeHtml(raw); + try { + const url = new URL(raw, location.origin); + if (url.protocol === "mailto:") return escapeHtml(url.href); + if (url.protocol !== "http:" && url.protocol !== "https:") return "#"; + return escapeHtml(url.href); + } catch { + return "#"; + } } const emoji = EMOJI_SHORTCODES; @@ -24,11 +32,11 @@ function inline(value) { html = html.replace(/`([^`]+)`/g, (_, code) => stash(`${code}`)); html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => { const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; - return stash(`${alt}`); + return stash(`${alt}`); }); html = html.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => { const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; - return stash(`${label}`); + return stash(`${label}`); }); html = html.replace(/\[\^([^\]\s]+)\]/g, (_, id) => stash(`?`)); @@ -44,7 +52,7 @@ function inline(value) { html = html.replace(/(^|[\s(])((?:https?:\/\/|mailto:)[^\s<]+)/gi, (match, prefix, url) => { const clean = url.replace(/[.,!?;:]+$/, ""); const suffix = url.slice(clean.length); - return `${prefix}${stash(`${clean}`)}${suffix}`; + return `${prefix}${stash(`${clean}`)}${suffix}`; }); return html.replace(/\u0000T(\d+)\u0000/g, (_, index) => tokens[Number(index)] || ""); diff --git a/static/js/note-files.js b/static/js/note-files.js index dfc0e83..c5a89c2 100644 --- a/static/js/note-files.js +++ b/static/js/note-files.js @@ -3,6 +3,7 @@ import { copyText } from "@rustpad/clipboard"; import { prepareImageFile } from "@rustpad/image-upload"; import { askConfirm } from "@rustpad/modal"; import { getAuthToken } from "@rustpad/session"; +import { safeAppUrl } from "@rustpad/security"; function escapeHtml(value) { return String(value).replace(/[&<>"']/g, character => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[character])); @@ -63,7 +64,8 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to form.append("file", file); try { const result = await api(endpoints.upload, { method: "POST", body: form, headers: {} }); - const text = file.type.startsWith("image/") ? `![${file.name}](${result.url})` : `[${file.name}](${result.url})`; + const fileUrl = safeAppUrl(result.url); + const text = file.type.startsWith("image/") ? `![${file.name}](${fileUrl})` : `[${file.name}](${fileUrl})`; editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end"); editor.dispatchEvent(new Event("input", { bubbles: true })); toast("File uploaded"); @@ -80,7 +82,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to if (showButton) { const panel = showButton.closest(".file-row").querySelector(".file-code"); const output = panel.querySelector("textarea"); - const absolute = new URL(showButton.dataset.url, location.origin).href; + const absolute = new URL(safeAppUrl(showButton.dataset.url), location.origin).href; let text = absolute; if (showButton.dataset.showFileCode === "markdown") text = showButton.dataset.mime?.startsWith("image/") ? `![${showButton.dataset.name}](${absolute})` : `[${showButton.dataset.name}](${absolute})`; output.value = text; panel.hidden = false; output.focus(); output.select(); return; diff --git a/static/js/public.js b/static/js/public.js index 28c55b9..9776062 100644 --- a/static/js/public.js +++ b/static/js/public.js @@ -26,7 +26,7 @@ function scrollToPublicAnchor(hash, behavior = "auto") { target.scrollIntoView({ behavior, block: "start" }); return true; } -async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`); document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { content.innerHTML = `

${String(error.message)}

`; } } +async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`); document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } } content.addEventListener("click", event => { const link = event.target.closest('.markdown-toc a[href^="#"]'); if (!link) return; diff --git a/static/js/security.js b/static/js/security.js new file mode 100644 index 0000000..d3a95e0 --- /dev/null +++ b/static/js/security.js @@ -0,0 +1,26 @@ +export function safeAppUrl(value, fallback = "/") { + try { + const url = new URL(String(value || ""), location.origin); + if (url.origin !== location.origin || !["http:", "https:"].includes(url.protocol)) return fallback; + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return fallback; + } +} + +export function safePublicUrl(value, { allowMailto = true } = {}) { + const raw = String(value || "").trim(); + if (!raw || raw.startsWith("//")) return "#"; + try { + const url = new URL(raw, location.origin); + if (url.protocol === "mailto:" && allowMailto) return url.href; + if (!["http:", "https:"].includes(url.protocol)) return "#"; + return url.href; + } catch { + return "#"; + } +} + +export function safeHexColor(value, fallback = "#64748b") { + return /^#[0-9a-f]{6}$/i.test(String(value || "")) ? String(value) : fallback; +} diff --git a/static/js/workspace.js b/static/js/workspace.js index 391733a..0e1cad5 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -6,6 +6,7 @@ import { copyText } from "@rustpad/clipboard"; import { getNickname, getAccessToken, getAuthToken, setAccessToken } from "@rustpad/session"; import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui"; import { askConfirm } from "@rustpad/modal"; +import { safeAppUrl } from "@rustpad/security"; const parts = location.pathname.split("/").filter(Boolean); const slug = parts[1]; @@ -64,7 +65,7 @@ function renderNotes(notes = notesCache) { if (notesView === "table") { notesList.innerHTML = `
${notes.map(note => ` - + @@ -74,7 +75,7 @@ function renderNotes(notes = notesCache) { } notesList.innerHTML = notes.map(note => `
- +

${escapeHtml(note.title)}

${note.protected ? 'Protected' : ''}
Created by: ${escapeHtml(note.created_by || "Unknown")}Updated: ${formatDate(note.updated_at)}
@@ -134,7 +135,7 @@ document.querySelector("#note-form").addEventListener("submit", async e => { method: "POST", body: JSON.stringify({ name: document.querySelector("#note-name").value, access_token: accessToken || null, protect: document.querySelector("#note-protect").checked, created_by: nickname || null }) }); - location.assign(`${note.url}?view=split&mode=markdown`); + location.assign(safeAppUrl(`${note.url}?view=split&mode=markdown`)); } catch (err) { error.textContent = err.message; } }); notesList.addEventListener("click", async event => {
NameCreated byStatusUpdatedActions
${escapeHtml(note.title)}${escapeHtml(note.title)} ${escapeHtml(note.created_by || "Unknown")} ${note.protected ? 'Protected' : 'Unprotected'} ${formatDate(note.updated_at)}