fix security 1
This commit is contained in:
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.0.41"
|
||||
version = "0.0.42"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -21,6 +21,7 @@ const MODULES: &[&str] = &[
|
||||
"session",
|
||||
"socket",
|
||||
"url-state",
|
||||
"security",
|
||||
];
|
||||
|
||||
pub fn render_html(
|
||||
|
||||
+5
-4
@@ -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;
|
||||
|
||||
+13
-5
@@ -5,10 +5,18 @@ function escapeHtml(value) {
|
||||
}
|
||||
|
||||
function safeUrl(value) {
|
||||
const url = String(value).trim();
|
||||
if (/^(https?:\/\/|mailto:|\/|\.\/|\.\.\/|#)/i.test(url)) return escapeHtml(url);
|
||||
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)] || "");
|
||||
|
||||
@@ -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})`;
|
||||
const fileUrl = safeAppUrl(result.url);
|
||||
const text = file.type.startsWith("image/") ? `` : `[${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})`;
|
||||
output.value = text; panel.hidden = false; output.focus(); output.select(); return;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user