diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d03e9d43..16346a82 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,6 +37,9 @@ jobs: - name: Install Dependencies run: npm ci + - name: Build Extension + run: npm run build:chrome + - uses: nick-fields/retry@v3 name: Run Test with: @@ -59,6 +62,9 @@ jobs: - name: Install Dependencies run: npm ci + - name: Build Extension + run: npm run build:chrome + - uses: nick-fields/retry@v3 name: Run Storage Test with: diff --git a/src/background/background.js b/src/background/background.js index bbc42d52..8715f6c4 100755 --- a/src/background/background.js +++ b/src/background/background.js @@ -87,7 +87,9 @@ export function initBackground() { // Remove DOM-dependent setFaviconCode since it's not needed in service worker const fetchOpenReviewNoteJSON = async (url) => { - const id = url.match(/id=([\w-])+/)[0].replace("id=", ""); + const match = url.match(/id=([\w-]+)/); + if (!match) return; + const id = match[1]; const api = `https://api.openreview.net/notes?id=${id}`; let response = await fetch(api); let json = await response.json(); @@ -101,7 +103,9 @@ export function initBackground() { }; const fetchOpenReviewForumJSON = async (url) => { - const id = url.match(/id=([\w-])+/)[0].replace("id=", ""); + const match = url.match(/id=([\w-]+)/); + if (!match) return; + const id = match[1]; const api = `https://api.openreview.net/notes?forum=${id}`; let response = await fetch(api); let json = await response.json(); @@ -161,29 +165,8 @@ export function initBackground() { }; const fetchPWCData = async (arxivId, title) => { - return; // PWC API discontinued, to fix later - let pwcPath = `https://paperswithcode.com/api/v1/papers/?`; - if (arxivId) { - log("Fetching PWC data for arxivId:", arxivId); - pwcPath += new URLSearchParams({ arxiv_id: arxivId }); - } else if (title) { - log("Fetching PWC data for paper:", title); - pwcPath += new URLSearchParams({ title }); - } - const response = await fetch(pwcPath); - try { - const json = await response.json(); - } catch (error) { - logError("[fetchPWCData]", error); - return; - } - - if (json["count"] !== 1) { - log("No PWC entry match."); - return; - } - log("PWC entry match:", json["results"][0]["id"]); - return json["results"][0]; + // PWC API discontinued, to fix later + return; }; const findCodesForPaper = async (request) => { @@ -323,7 +306,6 @@ export function initBackground() { const pushSyncPapers = async () => { if (!(await shouldSync())) return; - const identifier = await getIdentifier(); try { const start = Date.now(); consoleHeader(`Pushing ${String.fromCodePoint("0x23EB")}`); @@ -359,6 +341,9 @@ export function initBackground() { }; chrome.runtime.onMessage.addListener((payload, sender, sendResponse) => { + if (sender.id !== chrome.runtime.id) { + return; + } if (payload.type === "update-title") { const { title, url } = payload.options; paperTitles[url] = title.replaceAll('"', "'"); @@ -379,12 +364,15 @@ export function initBackground() { }); } const safeTitle = payload.title - .replaceAll("?", "") - .replaceAll(":", "") - .replaceAll("..", "") - .replaceAll("/", "_") - .replaceAll("\\", "_"); + .replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") + .replace(/\.{2,}/g, ".") + .replace(/^\.+|\.+$/g, "") + .slice(0, 200); const filename = "PaperMemoryStore/" + safeTitle; + if (!/^https?:\/\//.test(payload.pdfUrl)) { + sendResponse(false); + return; + } chrome.downloads.download({ url: payload.pdfUrl, filename }); sendResponse(true); }); @@ -434,11 +422,10 @@ export function initBackground() { console.log(">>> Setting tab title to :", paperTitle); chrome.scripting.executeScript({ target: { tabId }, - code: ` - ${setTitleCode(paperTitle)}; - ${setFaviconCode}; - `, - runAt: "document_start", + func: (title) => { + document.title = title; + }, + args: [paperTitle], }); } } diff --git a/src/bibMatcher/bibMatcher.js b/src/bibMatcher/bibMatcher.js index 7c0e9be1..84fca100 100644 --- a/src/bibMatcher/bibMatcher.js +++ b/src/bibMatcher/bibMatcher.js @@ -9,7 +9,7 @@ import { hideId, querySelector, } from "@pmu/miniquery.js"; -import { copyTextToClipboard } from "@pmu/functions.js"; +import { copyTextToClipboard, escapeHtml } from "@pmu/functions.js"; import { BibtexParser, bibtexToObject, bibtexToString } from "@pmu/bibtexParser.js"; import { tryDBLP, @@ -313,7 +313,7 @@ const matchItems = async (papersToMatch) => { setHTML("matching-status-index", idx + 1); setHTML( "matching-status-title", - paper.title.replaceAll("{", "").replaceAll("}", ""), + escapeHtml(paper.title.replaceAll("{", "").replaceAll("}", "")), ); changeProgress(parseInt((idx / papersToMatch.length) * 100)); @@ -365,10 +365,10 @@ const updateMatchedTitles = (matchedBibtexStrs, sources, venues) => { for (const [idx, title] of titles.entries()) { htmls.push( ` - ${keys[idx]} - ${title} - ${venues[idx]} - ${sources[idx]} + ${escapeHtml(keys[idx])} + ${escapeHtml(title)} + ${escapeHtml(venues[idx])} + ${escapeHtml(sources[idx])} `, ); } diff --git a/src/content_scripts/content_script.js b/src/content_scripts/content_script.js index 21e2c766..65029327 100644 --- a/src/content_scripts/content_script.js +++ b/src/content_scripts/content_script.js @@ -19,6 +19,7 @@ import { sendMessageToBackground, downloadURI, dummyEvent, + escapeHtml, } from "@pmu/functions.js"; import { bibtexToString, bibtexToObject } from "@pmu/bibtexParser.js"; import { @@ -135,7 +136,6 @@ $.extend($.easing, { }, }); -var PDF_TITLE_ITERS = 0; /** * Centralizes HTML svg codes @@ -254,8 +254,8 @@ const makePaperMemoryHTMLDiv = (paper) => { style="display: flex; justify-content: center; align-items: center;" id="pm-venue" > - ${paper.venue} - ${bibtexToObject(paper.bibtex).year} + ${escapeHtml(paper.venue)} + ${escapeHtml(bibtexToObject(paper.bibtex).year)}

{ const venueDiv = /*html*/ `

- ${paper.venue} - ${bibtexToObject(paper.bibtex).year} + ${escapeHtml(paper.venue)} + ${escapeHtml(bibtexToObject(paper.bibtex).year)}
`; findEl({ element: "pm-publication-wrapper" })?.remove(); @@ -627,9 +629,11 @@ const displayPaperCode = (paper) => { if (!paper.codeLink) { return; } + const safeLink = /^https?:\/\//.test(paper.codeLink) ? escapeHtml(paper.codeLink) : ""; + if (!safeLink) return; const code = /*html*/ `
-

Code:

${paper.codeLink} +

Code:

${safeLink}
`; findEl({ element: "pm-code" })?.remove(); @@ -648,14 +652,15 @@ const huggingfacePapers = (paper, url) => { const venueDiv = /*html*/ `
- ${paper.venue} - ${bibtexToObject(paper.bibtex).year} + ${escapeHtml(paper.venue)} + ${escapeHtml(bibtexToObject(paper.bibtex).year)} (PaperMemory)
`; - abstractH2 = queryAll("h2").find((h) => h.innerText.trim() === "Abstract"); + const abstractH2 = queryAll("h2").find((h) => h.innerText.trim() === "Abstract"); if (!abstractH2) { log("Missing 'Abstract' h2 title on HuggingFace paper page."); + return; } const authorDiv = abstractH2.parentElement.previousElementSibling; log("Adding venue to HuggingFace paper page."); @@ -852,7 +857,7 @@ const updateCompleteSecretHTML = (paper) => { .querySelector("head") .insertAdjacentHTML( "beforeend", - /*html*/ ``, + /*html*/ ``, ); } }, 50); diff --git a/src/options/options.js b/src/options/options.js index 3611fce4..1dea8bf0 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -32,6 +32,7 @@ import { cleanPapers, sendMessageToBackground, parseTags, + escapeHtml, } from "@pmu/functions.js"; import { makePaper, @@ -140,7 +141,7 @@ const handleSelectImportJson = () => { findEl({ element: "import-json-papers-button" }).disabled = false; }; -const validateImportPaper = (p) => { +const validateImportPaper = (p, i) => { if (typeof p === "string") { if (!isValidHttpUrl(p)) { alert(`${p} (entry ${i}) is not a valid URL`); @@ -213,7 +214,7 @@ const handleParseImportJson = async (e) => { throw new Error("The JSON file must contain a *list* of papers"); } for (const [i, p] of papersToParse.entries()) { - if (!validateImportPaper(p)) { + if (!validateImportPaper(p, i)) { return; } } @@ -248,7 +249,7 @@ const handleParseImportJson = async (e) => { if (!Object.values(is).some((i) => i)) { feedback.innerHTML += `
  • [${ i + 1 - }]  ×  Error: ${url} does not come from a known source
  • `; + }]  ×  Error: ${escapeHtml(url)} does not come from a known source`; warn("Aborting."); } else { let paper; @@ -266,12 +267,12 @@ const handleParseImportJson = async (e) => { if (exists) { feedback.innerHTML += `
  • [${ i + 1 - }]  ×  Warning: ${url} already exists and has been ignored
  • `; + }]  ×  Warning: ${escapeHtml(url)} already exists and has been ignored`; warn("Aborting."); } else { feedback.innerHTML += `
  • [${ i + 1 - }]  ✔ ${url} has been successfully added to your Memory!
  • `; + }]  ✔ ${escapeHtml(url)} has been successfully added to your Memory!`; } } } catch (error) { @@ -279,7 +280,7 @@ const handleParseImportJson = async (e) => { warn("Aborting."); feedback.innerHTML += `
  • [${ i + 1 - }]  ×  Error: ${url} (open the JavaScript Console for more info)
  • `; + }]  ×  Error: ${escapeHtml(url)} (open the JavaScript Console for more info)`; } } await pushToRemote(); @@ -348,13 +349,13 @@ const getAutoTagHTML = (at) => { return /*html*/ `
    - +
    - +
    - +
    @@ -496,14 +497,14 @@ const addPreprintUpdate = (update) => { for (const [k, v] of Object.entries(update)) { if (k !== "paper" && k !== "bibtex") { if (v) { - contents.push(`${k}: ${v}`); + contents.push(`${escapeHtml(k)}: ${escapeHtml(v)}`); } } } contents = contents.join("
    "); const html = /*html*/ `
    -

    ${paper.title}

    +

    ${escapeHtml(paper.title)}

    Updates to approve:
    @@ -555,13 +556,13 @@ const startMatching = async (papersToMatch) => { for (const [idx, paper] of papersToMatch.entries()) { console.log("idx: ", idx); setHTML("matching-status-index", idx + 1); - setHTML("matching-status-title", paper.title); + setHTML("matching-status-title", escapeHtml(paper.title)); changeProgress(parseInt((idx / papersToMatch.length) * 100)); var bibtex, venue, note, code, match; setHTML("matching-status-provider", "paperswithcode.org ..."); - pwcMatch = await tryPWCMatch(paper); + const pwcMatch = await tryPWCMatch(paper); console.log("pwcMatch: ", pwcMatch); code = !paper.codeLink && pwcMatch?.url; note = !paper.note && pwcMatch?.note; @@ -918,7 +919,7 @@ const setupSync = async () => { if (!ok) { if (error) { - setHTML("pat-feedback", "Invalid PAT" + "

    " + error); + setHTML("pat-feedback", "Invalid PAT" + "

    " + escapeHtml(String(error))); } hideId("pat-loader"); await toggleSync({ hideAll: true }); @@ -943,12 +944,12 @@ const setupSync = async () => { const { ok, payload, error } = await getGist({ pat }); if (!ok) { logError(error); - setHTML("pat-feedback", error.response.data.message); + setHTML("pat-feedback", escapeHtml(String(error.response.data.message))); } else { const { file, pat, gistId } = payload; log("Gist ID", gistId); log("Data URL", file.raw_url); - log("Personal Access Token", pat); + log("Personal Access Token", "[REDACTED]"); setHTML("pat-feedback", "Ok! Token is valid."); toggleSync(); } @@ -1092,7 +1093,7 @@ const setupSync = async () => { } } } catch (e) { - setHTML("overwriteRemoteFeedback", e); + setHTML("overwriteRemoteFeedback", escapeHtml(String(e))); } await sendMessageToBackground({ type: "restartGist" }); hideId("sync-loader"); diff --git a/src/popup/js/templates.js b/src/popup/js/templates.js index 839d4df2..12e4ff22 100644 --- a/src/popup/js/templates.js +++ b/src/popup/js/templates.js @@ -1,6 +1,6 @@ // ES Module imports import { state, knownPaperPages, svgActionsHoverTitles } from "@pmu/config.js"; -import { getDisplayId, tablerSvg, isPdfUrl, cutAuthors } from "@pmu/functions.js"; +import { getDisplayId, tablerSvg, isPdfUrl, cutAuthors, escapeHtml } from "@pmu/functions.js"; import { getTagsOptions } from "@pmu/state.js"; import { isPaper } from "@pmu/paper.js"; /** @@ -21,7 +21,7 @@ export const getPaperInfoTable = (paper) => { if (paper.venue) tableData.push([ "Publication", - `${paper.venue} ${paper.year}`, + `${escapeHtml(paper.venue)} ${escapeHtml(String(paper.year))}`, ]); return /*html*/ ` @@ -53,7 +53,7 @@ export const getMemoryItemHTML = (paper) => { const favoriteClass = paper.favorite ? "favorite" : ""; const titles = { ...svgActionsHoverTitles }; // titles behave differently in build/watch mode. This works in build - titles.pdfLink = `Open tab to ${paper.title}`; + titles.pdfLink = `Open tab to ${escapeHtml(paper.title)}`; titles.copyLink = `Copy URL to the paper's ${ state.prefs.checkPreferPdf ? "PDF" : "abstract" }`; @@ -62,7 +62,7 @@ export const getMemoryItemHTML = (paper) => {
    ${ @@ -78,7 +78,7 @@ export const getMemoryItemHTML = (paper) => { noteDiv = /*html*/ `
    Note: - ${note} + ${escapeHtml(note)}
    `; } @@ -163,7 +163,7 @@ export const getMemoryItemHTML = (paper) => { favoriteClass, ])} - ${paper.title} + ${escapeHtml(paper.title)} @@ -171,7 +171,7 @@ export const getMemoryItemHTML = (paper) => {
    ${[...tags] - .map((t) => `${t}`) + .map((t) => `${escapeHtml(t)}`) .join("")}
    - ${cutAuthors(paper.author)} + ${cutAuthors(escapeHtml(paper.author))}
    ${codeDiv} ${noteDiv}
    @@ -256,7 +256,7 @@ export const getMemoryItemHTML = (paper) => { @@ -267,7 +267,7 @@ export const getMemoryItemHTML = (paper) => { class="form-note-textarea" placeholder="Anything to note?" > -${note}
    @@ -324,7 +324,7 @@ export const getPopupEditFormHTML = (paper) => { id="popup-form-codeLink--${id}" type="text" class="form-code-input mt-0" - value="${paper.codeLink || ""}" + value="${escapeHtml(paper.codeLink || "")}" placeholder="Add code link" />
    @@ -336,7 +336,7 @@ export const getPopupEditFormHTML = (paper) => { id="popup-form-note-textarea--${id}" placeholder="Anything to note?" > -${note} diff --git a/src/shared/js/utils/config.js b/src/shared/js/utils/config.js index e516de0a..e4e49a79 100644 --- a/src/shared/js/utils/config.js +++ b/src/shared/js/utils/config.js @@ -6,7 +6,7 @@ import { miniHash } from "@pmu/functions.js"; if (!Array.prototype.last) { Object.defineProperty(Array.prototype, "last", { value: function (i = 0) { - return this.reverse()[i]; + return this[this.length - 1 - i]; }, configurable: true, }); diff --git a/src/shared/js/utils/data.js b/src/shared/js/utils/data.js index 3ca2a993..28eb7ace 100644 --- a/src/shared/js/utils/data.js +++ b/src/shared/js/utils/data.js @@ -11,6 +11,7 @@ import { miniHash, parseUrl, stringifyError, + escapeHtml, } from "@pmu/functions.js"; import { state, @@ -495,9 +496,9 @@ export const versionToSemantic = (dataVersionInt) => { // 209 -> 0.2.9 // 1293 -> 0.12.93 // 23439 -> 2.34.39 - major = parseInt(dataVersionInt / 1e4, 10); + const major = parseInt(dataVersionInt / 1e4, 10); dataVersionInt -= major * 1e4; - minor = parseInt(dataVersionInt / 1e2, 10); + const minor = parseInt(dataVersionInt / 1e2, 10); dataVersionInt -= minor * 1e2; return `${major}.${minor}.${dataVersionInt}`; }; @@ -811,11 +812,15 @@ export const prepareOverwriteData = async (data) => { for (const id in papersToWrite) { if (!id.startsWith("__")) { let paperWarnings = validatePaper(papersToWrite[id]).warnings; - if (paperWarnings && paperWarnings.length > 0) { + const hasWarnings = Object.values(paperWarnings).some( + (v) => v.length > 0, + ); + if (hasWarnings) { warning += "
    " + Object.entries(paperWarnings) - .map((k, v) => `
    ${k}
    ${v.join("
    ")}`) + .filter(([, v]) => v.length > 0) + .map(([k, v]) => `
    ${escapeHtml(k)}
    ${v.map(escapeHtml).join("
    ")}`) .join("
    "); } } diff --git a/src/shared/js/utils/functions.js b/src/shared/js/utils/functions.js index d98459a6..9ec65b82 100644 --- a/src/shared/js/utils/functions.js +++ b/src/shared/js/utils/functions.js @@ -9,6 +9,21 @@ import { import { LOGTRACE } from "@pmu/logTrace.js"; import { val, hasClass, findEl } from "@pmu/miniquery.js"; +/** + * Escapes HTML special characters to prevent XSS when inserting into HTML. + * @param {string} str The string to escape + * @returns {string} The escaped string + */ +export const escapeHtml = (str) => { + if (typeof str !== "string") return String(str ?? ""); + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +}; + /** * Generate a random integer between 0 and max * @param {number} max The maximum value of the random integer @@ -357,7 +372,7 @@ async function pasteRich(rich, plain) { * @returns {void} * */ export const copyHyperLinkToClipboard = (url, title) => { - const linkHtml = `${title}`; + const linkHtml = `${escapeHtml(title)}`; pasteRich(linkHtml, `${title} ${url}`); }; @@ -786,10 +801,12 @@ export const stringifyError = (e) => { return e.stack .split("\n") .map((line) => - line - .split(" ") - .map((word) => word.split(extId).last()) - .join(" "), + escapeHtml( + line + .split(" ") + .map((word) => word.split(extId).last()) + .join(" "), + ), ) .join("
    "); }; diff --git a/src/shared/js/utils/paper.js b/src/shared/js/utils/paper.js index 62a7e9fd..4d9cef93 100644 --- a/src/shared/js/utils/paper.js +++ b/src/shared/js/utils/paper.js @@ -441,11 +441,9 @@ export const mergePapers = (options = { newPaper: {}, oldPaper: {} }) => { if (newPaper.lastOpenDate > oldPaper.lastOpenDate) { mergedPaper.lastOpenDate = newPaper.lastOpenDate; } - mergedPaper.addDate = newPaper.addDate; // keep oldest add date - if (newPaper.addDate > oldPaper.addDate) { - mergedPaper.addDate = newPaper.addDate; - } + mergedPaper.addDate = + newPaper.addDate < oldPaper.addDate ? newPaper.addDate : oldPaper.addDate; } for (const attribute of opts.overwrites) { if (newPaper.hasOwnProperty(attribute)) { @@ -477,7 +475,12 @@ export const addOrUpdatePaper = async ({ prefs, tab, store = true, - contentScriptCallbacks = { update: () => {}, preprints: () => {}, feedback: null }, + contentScriptCallbacks = { + update: () => {}, + preprints: () => {}, + done: () => {}, + feedback: null, + }, }) => { // start time const aouStart = Date.now(); diff --git a/src/shared/js/utils/parsers.js b/src/shared/js/utils/parsers.js index 894cabfe..b52dc451 100644 --- a/src/shared/js/utils/parsers.js +++ b/src/shared/js/utils/parsers.js @@ -173,11 +173,11 @@ export const extractAuthor = (bibtex) => export const extractCrossrefData = (crossrefResponse) => { if (!crossrefResponse.status || crossrefResponse.status !== "ok") { - error("Cannot parse CrossRef response", crossrefResponse); + logError("Cannot parse CrossRef response", crossrefResponse); return; } if (crossrefResponse["message-type"] !== "work") { - error("Unknown `message-type` from CrossRef", crossrefResponse); + logError("Unknown `message-type` from CrossRef", crossrefResponse); return; } @@ -193,14 +193,14 @@ export const extractCrossrefData = (crossrefResponse) => { : null; if (!year) { - error("Cannot find year in CrossRef data", data); + logError("Cannot find year in CrossRef data", data); return; } const title = data.title[0]; if (!title) { - error("Cannot find title in CrossRef data", data); + logError("Cannot find title in CrossRef data", data); return; } @@ -1924,12 +1924,18 @@ export const autoTagPaper = async (paper) => { if (!at.tags?.length) continue; if (!at.title && !at.author) continue; - const titleMatch = at.title - ? new RegExp(at.title, "i").test(paper.title) - : true; - const authorMatch = at.author - ? new RegExp(at.author, "i").test(paper.author) - : true; + let titleMatch = true; + let authorMatch = true; + try { + if (at.title) { + titleMatch = new RegExp(at.title, "i").test(paper.title); + } + if (at.author) { + authorMatch = new RegExp(at.author, "i").test(paper.author); + } + } catch (e) { + continue; + } if (titleMatch && authorMatch) { at.tags.forEach((t) => tags.add(t));