Fix security bugs and migrate image uploads to /api/v1/attachments

* Replace /api/v1/resources with /api/v1/attachments for image uploads
* Upload attachments as JSON with base64-encoded content field
* After memo creation, link each attachment via PATCH /api/v1/attachments/{id}
* Rewrite markdown image URLs to use /file/attachments/{id} pattern
* Fix XSS: sanitize marked.parse output with a DOM-based allowlist sanitizer
* Fix SSRF: validate img.src scheme (http/https only) before fetching
* Fix stack overflow: use chunked base64 encoding for large images
* Update CLAUDE.md to document new attachment flow
This commit is contained in:
2026-03-18 17:55:04 +01:00
parent 42d9f336b1
commit 84b3dd69f1
11 changed files with 274 additions and 119 deletions

View File

@@ -124,10 +124,54 @@ document.querySelectorAll(".tab").forEach((btn) => {
});
// ── Preview ───────────────────────────────────────────────────────────────────
function sanitizeHtml(html) {
const doc = new DOMParser().parseFromString(html, "text/html");
const allowed = new Set([
"p","br","b","strong","i","em","u","s","del","ins","code","pre","blockquote",
"h1","h2","h3","h4","h5","h6","ul","ol","li","a","img","table","thead",
"tbody","tr","th","td","hr","span","div",
]);
const allowedAttrs = {
a: ["href","title"],
img: ["src","alt","title","width","height"],
td: ["colspan","rowspan"], th: ["colspan","rowspan"],
code:["class"], pre: ["class"],
};
function clean(node) {
for (const child of [...node.childNodes]) {
if (child.nodeType === Node.ELEMENT_NODE) {
const tag = child.tagName.toLowerCase();
if (!allowed.has(tag)) {
// Replace disallowed element with its text content
child.replaceWith(document.createTextNode(child.textContent));
continue;
}
// Strip disallowed attributes
for (const attr of [...child.attributes]) {
const ok = (allowedAttrs[tag] || []).includes(attr.name);
if (!ok) { child.removeAttribute(attr.name); continue; }
// Block javascript: and data: in href/src
if (attr.name === "href" || attr.name === "src") {
const v = attr.value.trim().toLowerCase();
if (v.startsWith("javascript:") || v.startsWith("data:text")) {
child.removeAttribute(attr.name);
}
}
}
clean(child);
}
}
}
clean(doc.body);
return doc.body.innerHTML;
}
function renderPreview() {
state.markdown = mdEditor.value;
if (typeof marked !== "undefined") {
mdPreview.innerHTML = marked.parse(state.markdown, { breaks: true });
mdPreview.innerHTML = sanitizeHtml(marked.parse(state.markdown, { breaks: true }));
} else {
// Fallback: basic escaping
mdPreview.innerHTML = `<pre style="white-space:pre-wrap">${escHtml(state.markdown)}</pre>`;
@@ -251,23 +295,16 @@ sendBtn.addEventListener("click", async () => {
sendBtn.disabled = true;
try {
let resourceNames = [];
// 1. Upload images if requested
const imageMap = new Map(); // originalUrl -> resourceName
// 1. Upload images as attachments if requested (without memo link yet)
const imageMap = new Map(); // originalUrl -> attachment name ("attachments/{id}")
if (attachCheck.checked) {
const toUpload = state.images.filter((img) => img.keep);
let uploaded = 0;
for (const img of toUpload) {
sendBtn.textContent = `Uploading image ${uploaded + 1}/${toUpload.length}`;
try {
const resource = await uploadImage(baseUrl, token, img);
if (resource) {
// v0.22+ uses resource.name; older APIs return resource.id
const name = resource.name || `resources/${resource.id}`;
resourceNames.push(name);
imageMap.set(img.src, name);
}
const attachment = await uploadAttachment(baseUrl, token, img);
imageMap.set(img.src, attachment.name);
uploaded++;
} catch (e) {
console.warn("Failed to upload image:", img.src, e.message);
@@ -277,44 +314,28 @@ sendBtn.addEventListener("click", async () => {
sendBtn.textContent = "Creating memo…";
// 2. Create memo
// Try to replace original image URLs with local attachment URLs in the markdown
// 2. Replace original image URLs in markdown with attachment external links
let contentWithImages = finalContent;
for (const [origUrl, resName] of imageMap.entries()) {
const urlPart = resName.startsWith("resources/") ? resName : `resources/${resName}`;
const attachmentUrl = `${baseUrl}/file/${urlPart}`;
// Escape for regex
for (const [origUrl, attachName] of imageMap.entries()) {
const attachmentUrl = `${baseUrl}/file/${attachName}`;
const escapedUrl = origUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`!\\[(.*?)\\]\\(${escapedUrl}\\)`, 'g');
contentWithImages = contentWithImages.replace(re, `![$1](${attachmentUrl})`);
}
// Append any uploaded images that weren't already in the markdown (e.g. from gallery)
for (const resName of imageMap.values()) {
const urlPart = resName.startsWith("resources/") ? resName : `resources/${resName}`;
if (!contentWithImages.includes(`/file/${urlPart}`)) {
contentWithImages += `\n\n![attachment](${baseUrl}/file/${urlPart})`;
}
// Append any uploaded attachments not already referenced in the markdown
for (const attachName of imageMap.values()) {
const attachmentUrl = `${baseUrl}/file/${attachName}`;
if (!contentWithImages.includes(attachmentUrl)) {
contentWithImages += `\n\n![attachment](${attachmentUrl})`;
}
}
// 3. Create the memo
const body = {
content: contentWithImages,
visibility,
};
// Also set resources array for native attachment display (v0.22+)
if (resourceNames.length) {
body.resources = resourceNames.map((name) => {
const norm = name.startsWith("resources/") ? name : `resources/${name}`;
return { name: norm };
});
// Backward compatibility: some versions use resourceIdList (array of ints)
const resourceIds = resourceNames
.map(n => parseInt(n.replace("resources/", "")))
.filter(id => !isNaN(id));
if (resourceIds.length === resourceNames.length) {
body.resourceIdList = resourceIds;
}
}
const res = await fetch(`${baseUrl}/api/v1/memos`, {
method: "POST",
@@ -331,10 +352,28 @@ sendBtn.addEventListener("click", async () => {
}
const memo = await res.json();
// Handle both v0.22+ (memo.name = "memos/123") and older (memo.id = "123" or memo.uid)
// memo.name is "memos/{id}"
const memoName = memo.name;
// 4. Link each attachment to the created memo via PATCH
for (const attachName of imageMap.values()) {
try {
await fetch(`${baseUrl}/api/v1/${attachName}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ memo: memoName }),
});
} catch (e) {
console.warn("Failed to link attachment to memo:", attachName, e.message);
}
}
let memoId = "";
if (memo.name) {
memoId = memo.name.replace(/^memos\//, "");
if (memoName) {
memoId = memoName.replace(/^memos\//, "");
} else if (memo.uid) {
memoId = memo.uid;
} else if (memo.id) {
@@ -352,8 +391,17 @@ sendBtn.addEventListener("click", async () => {
}
});
// ── Upload a single image resource ────────────────────────────────────────────
async function uploadImage(baseUrl, token, img) {
// ── Upload a single image as an attachment ────────────────────────────────────
async function uploadAttachment(baseUrl, token, img) {
// Validate URL scheme to prevent SSRF via crafted page image URLs
if (!img.src.startsWith("data:")) {
let parsedUrl;
try { parsedUrl = new URL(img.src); } catch (_) { throw new Error(`Invalid image URL`); }
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
throw new Error(`Unsupported image URL scheme: ${parsedUrl.protocol}`);
}
}
const response = await fetch(img.src);
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
const blob = await response.blob();
@@ -361,11 +409,9 @@ async function uploadImage(baseUrl, token, img) {
// Derive a filename with a valid extension
let filename = "image";
if (!img.src.startsWith("data:")) {
filename = img.src.split("/").pop().split("?")[0].split("#")[0] || "image";
filename = img.src.split("/").pop().split("?")[0].split("#")[0] || "image";
}
// Strip non-filename characters
filename = filename.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
// If no extension, infer from MIME type
if (!/\.\w{2,5}$/.test(filename)) {
const mimeToExt = {
"image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png",
@@ -377,21 +423,38 @@ async function uploadImage(baseUrl, token, img) {
}
if (!filename || filename === ".") filename = "image.jpg";
const formData = new FormData();
formData.append("file", blob, filename);
// Encode blob as base64 for the JSON body (protobuf bytes → base64 in JSON)
// Chunked to avoid stack overflow when spreading large Uint8Arrays as arguments
const arrayBuffer = await blob.arrayBuffer();
const uint8 = new Uint8Array(arrayBuffer);
let binary = "";
const CHUNK = 8192;
for (let i = 0; i < uint8.length; i += CHUNK) {
binary += String.fromCharCode(...uint8.subarray(i, i + CHUNK));
}
const base64 = btoa(binary);
const res = await fetch(`${baseUrl}/api/v1/resources`, {
const res = await fetch(`${baseUrl}/api/v1/attachments`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
filename,
type: blob.type || "application/octet-stream",
content: base64,
}),
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`Resource upload failed ${res.status}: ${txt.slice(0, 120)}`);
throw new Error(`Attachment upload failed ${res.status}: ${txt.slice(0, 120)}`);
}
const resource = await res.json();
// Normalise the returned name — could be "resources/abc", "abc", or an int id
if (resource.name) return resource;
if (resource.id) return { ...resource, name: `resources/${resource.id}` };
throw new Error("Resource upload returned unexpected shape: " + JSON.stringify(resource).slice(0, 80));
const attachment = await res.json();
if (!attachment.name) {
throw new Error("Attachment upload returned unexpected shape: " + JSON.stringify(attachment).slice(0, 80));
}
return attachment;
}