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 = `
Delete “${item.title}” permanently?
Delete “${escapeHtml(item.title)}” permanently?
${code}`));
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
- return stash(`${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 = `| Name | Created by | Status | Updated | Actions |
|---|---|---|---|---|
| ${escapeHtml(note.title)} | +${escapeHtml(note.title)} | ${escapeHtml(note.created_by || "Unknown")} | ${note.protected ? 'Protected' : 'Unprotected'} | ${formatDate(note.updated_at)} | @@ -74,7 +75,7 @@ function renderNotes(notes = notesCache) { } notesList.innerHTML = notes.map(note => `