fix security 1

This commit is contained in:
Mateusz Gruszczyński
2026-07-26 16:21:58 +02:00
parent a075d06b6d
commit 364af50a7c
9 changed files with 57 additions and 18 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.0.41"
version = "0.0.42"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -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"
+1
View File
@@ -21,6 +21,7 @@ const MODULES: &[&str] = &[
"session",
"socket",
"url-state",
"security",
];
pub fn render_html(
+5 -4
View File
@@ -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 ? `<span class="resource-shared-badge">Shared by ${escapeHtml(item.shared_by || "another user")}</span>` : "";
const permissionLabel = item.permission === "rw" ? "Read and write" : "Read only";
row.classList.toggle("resource-row--shared", !Boolean(item.owned));
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${item.url}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button class="action-button action-button--primary compact-button" type="button" data-share>Share</button><button class="action-button action-button--secondary compact-button" type="button" data-password>Change password</button><button class="action-button action-button--danger compact-button" type="button" data-delete>Delete</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button class="action-button action-button--primary compact-button" type="button" data-share>Share</button><button class="action-button action-button--secondary compact-button" type="button" data-password>Change password</button><button class="action-button action-button--danger compact-button" type="button" data-delete>Delete</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
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 = `<div class="resource-delete-confirm"><p>Delete “${item.title}” permanently?</p><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="danger-button" type="button" data-confirm-delete>Delete</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></div>`;
inline.innerHTML = `<div class="resource-delete-confirm"><p>Delete “${escapeHtml(item.title)}” permanently?</p><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="danger-button" type="button" data-confirm-delete>Delete</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></div>`;
inline.querySelector("[data-cancel]").addEventListener("click", closeInline);
inline.querySelector("[data-confirm-delete]").addEventListener("click", async event => {
event.currentTarget.disabled = true;
+14 -6
View File
@@ -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>${code}</code>`));
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return stash(`<img src="${safeUrl(url)}" alt="${alt}" loading="lazy" decoding="async" draggable="false" contenteditable="false"${titleAttr}>`);
return stash(`<img src="${safeUrl(url)}" alt="${alt}" loading="lazy" decoding="async" referrerpolicy="no-referrer" draggable="false" contenteditable="false"${titleAttr}>`);
});
html = html.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return stash(`<a href="${safeUrl(url)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${label}</a>`);
return stash(`<a href="${safeUrl(url)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer"${titleAttr}>${label}</a>`);
});
html = html.replace(/\[\^([^\]\s]+)\]/g, (_, id) => stash(`<sup class="footnote-ref"><a href="#fn-${escapeHtml(id)}" id="fnref-${escapeHtml(id)}">?</a></sup>`));
@@ -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(`<a href="${safeUrl(clean)}" target="_blank" rel="noopener noreferrer">${clean}</a>`)}${suffix}`;
return `${prefix}${stash(`<a href="${safeUrl(clean)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">${clean}</a>`)}${suffix}`;
});
return html.replace(/\u0000T(\d+)\u0000/g, (_, index) => tokens[Number(index)] || "");
+4 -2
View File
@@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[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;
+1 -1
View File
@@ -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 = `<p class="error">${String(error.message)}</p>`; } }
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;
+26
View File
@@ -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;
}
+4 -3
View File
@@ -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 = `<div class="notes-table-scroll"><table><thead><tr><th>Name</th><th>Created by</th><th>Status</th><th>Updated</th><th class="notes-table-actions">Actions</th></tr></thead><tbody>${notes.map(note => `
<tr>
<td><a class="note-table-link" href="${note.url}?view=split&mode=markdown">${escapeHtml(note.title)}</a></td>
<td><a class="note-table-link" href="${escapeHtml(safeAppUrl(`${note.url}?view=split&mode=markdown`))}">${escapeHtml(note.title)}</a></td>
<td class="note-author">${escapeHtml(note.created_by || "Unknown")}</td>
<td>${note.protected ? '<span class="protect-badge">Protected</span>' : '<span class="note-status">Unprotected</span>'}</td>
<td>${formatDate(note.updated_at)}</td>
@@ -74,7 +75,7 @@ function renderNotes(notes = notesCache) {
}
notesList.innerHTML = notes.map(note => `
<article class="note-card-wrap">
<a class="note-card" href="${note.url}?view=split&mode=markdown">
<a class="note-card" href="${escapeHtml(safeAppUrl(`${note.url}?view=split&mode=markdown`))}">
<div class="note-card-title"><h3>${escapeHtml(note.title)}</h3>${note.protected ? '<span class="protect-badge">Protected</span>' : ''}</div>
<div class="note-card-meta"><span>Created by: ${escapeHtml(note.created_by || "Unknown")}</span><span>Updated: ${formatDate(note.updated_at)}</span></div>
</a>
@@ -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 => {