Supplements: save product URL for future reference#236
Conversation
Persists the URL pasted into the supplement editor as `entry.sourceUrl` so users can re-open the source page later. URL field is now visible even without an AI provider (Fetch button stays AI-gated). List rows show a hostname link next to the date range.
|
@Savi-1 is attempting to deploy a commit to the elkimek's projects Team on Vercel. A member of the Team first needs to authorize it. |
Greptile SummaryThis PR persists the product URL entered in the supplement editor as
Confidence Score: 3/5The feature works correctly for the normal save flow, but the list renderer trusts stored URLs unconditionally — a crafted import or sync payload can place a javascript: URL into an href. The URL field and save logic are well-structured, and the CSS changes are safe. The gap is in the rendering step: s.sourceUrl is written into an href without re-checking its scheme, while the rest of the codebase consistently validates URLs to http/https before rendering. Externally-supplied data (JSON import, CRDT sync) skips the save-time guard entirely, so a maliciously crafted entry could produce a live XSS vector in the supplement list. js/supplements.js — specifically the hostname-extraction block and the href template literal in openSupplementsEditor()
|
| Filename | Overview |
|---|---|
| js/supplements.js | Adds sourceUrl persistence, form visibility, and list-row link rendering. Save path validates http/https correctly, but the render path inserts s.sourceUrl into an href without a scheme re-check, leaving a defense-in-depth gap for externally-injected javascript: URLs. |
| styles.css | Adds two CSS rules for the new .supp-list-source link — color and hover underline. Straightforward and consistent with the design system. |
Sequence Diagram
sequenceDiagram
participant User
participant Form as Supplement Form
participant Save as saveSupplement
participant Store as importedData
participant List as openSupplementsEditor
participant Browser
User->>Form: Paste product URL
Form->>Save: value of supp-url input
Save->>Save: validate protocol http or https
alt Invalid or non-http/https
Save-->>User: showNotification error
else Valid
Save->>Store: "entry.sourceUrl = parsed URL"
end
Note over Store: sourceUrl also arrives via CRDT sync or JSON import with no protocol gate
Store->>List: s.sourceUrl read from data
List->>List: extract hostname via new URL
List->>Browser: render href with escapeHTML only, no scheme re-check
Reviews (1): Last reviewed commit: "Supplements: save product URL for future..." | Re-trigger Greptile
| let sourceHost = ''; | ||
| if (s.sourceUrl) { | ||
| try { sourceHost = new URL(s.sourceUrl).hostname.replace(/^www\./, ''); } catch { sourceHost = ''; } | ||
| } |
There was a problem hiding this comment.
The
href uses s.sourceUrl without a scheme check at render time. While saveSupplement validates the protocol, sourceUrl can also arrive via CRDT sync, JSON import, or direct importedData manipulation. A URL of the form javascript://hostname.com/... would have a non-empty hostname (passing the sourceHost truthy check) and be rendered as a clickable XSS-capable link. The fix is to add the same http:/https: guard inside the existing try block that extracts sourceHost, so both variables are derived from the same validated parse.
| let sourceHost = ''; | |
| if (s.sourceUrl) { | |
| try { sourceHost = new URL(s.sourceUrl).hostname.replace(/^www\./, ''); } catch { sourceHost = ''; } | |
| } | |
| let sourceHost = ''; | |
| let safeSourceUrl = ''; | |
| if (s.sourceUrl) { | |
| try { | |
| const _parsedUrl = new URL(s.sourceUrl); | |
| if (_parsedUrl.protocol === 'http:' || _parsedUrl.protocol === 'https:') { | |
| sourceHost = _parsedUrl.hostname.replace(/^www\./, ''); | |
| safeSourceUrl = s.sourceUrl; | |
| } | |
| } catch { sourceHost = ''; } | |
| } |
| <div class="supp-list-info"> | ||
| <div class="supp-list-name">${escapeHTML(s.name)}${s.dosage ? ` <span class="supp-list-meta">${escapeHTML(s.dosage)}</span>` : ''}</div> | ||
| <div class="supp-list-meta">${dateRange}</div> | ||
| <div class="supp-list-meta">${dateRange}${sourceHost ? ` · <a href="${escapeHTML(s.sourceUrl)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()" class="supp-list-source">${escapeHTML(sourceHost)} ↗</a>` : ''}</div> |
There was a problem hiding this comment.
The
href here should use safeSourceUrl (the scheme-validated value) rather than the raw s.sourceUrl. Without this pairing, the scheme guard on sourceHost is bypassed at the href level.
| <div class="supp-list-meta">${dateRange}${sourceHost ? ` · <a href="${escapeHTML(s.sourceUrl)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()" class="supp-list-source">${escapeHTML(sourceHost)} ↗</a>` : ''}</div> | |
| <div class="supp-list-meta">${dateRange}${sourceHost ? ` · <a href="${escapeHTML(safeSourceUrl)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()" class="supp-list-source">${escapeHTML(sourceHost)} ↗</a>` : ''}</div> |
Summary
entry.sourceUrlso it survives saves and re-opens.hostname ↗link in the supplement list next to the date range (opens in new tab; click doesn't toggle the accordion).http/httpsonly) before save to blockjavascript:/data:URLs.sourceUrlis just a new optional string field on supplement entries — no migration, no schema bump. Per-row CRDT sync picks it up automatically sincesupplementsis inDELTA_ARRAYS.Test plan
hostname ↗link; clicking the link opens the page in a new tab without expanding the accordionnot-a-urlorjavascript:alert(1)) is rejected at save with an error notificationsourceUrlrender as before (no broken link)