Merge pull request #4 from DanConwayDev/address-XSS

address XSS #2
This commit is contained in:
DanConwayDev
2023-06-07 11:36:18 +00:00
committed by GitHub
8 changed files with 2879 additions and 9054 deletions

6475
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,7 @@
}, },
"dependencies": { "dependencies": {
"@picocss/pico": "^1.5.7", "@picocss/pico": "^1.5.7",
"isomorphic-dompurify": "^1.6.0",
"nostr-tools": "^1.5.0", "nostr-tools": "^1.5.0",
"timeago.js": "^4.0.2", "timeago.js": "^4.0.2",
"websocket-polyfill": "^0.0.3" "websocket-polyfill": "^0.0.3"

View File

@@ -2,7 +2,7 @@
Lightweight typescript micro app for basic nostr profile management. Current USP is offline backup and restore. Lightweight typescript micro app for basic nostr profile management. Current USP is offline backup and restore.
Only javascript dependency is [nostr-tools](https://github.com/nbd-wtf/nostr-tools). no JS frameworks. no state management tools. Minimial javascript dependencies. no JS frameworks. no state management tools.
## Live instances ## Live instances
@@ -55,7 +55,6 @@ Supported profile events: kind `0`, `10002` and `3`.
- [ ] look far and wide for events - [ ] look far and wide for events
- cycle through all known relays to find current and previous versions of profile events to enable restoration. reccommended only when accessed through a VPN - cycle through all known relays to find current and previous versions of profile events to enable restoration. reccommended only when accessed through a VPN
##### Lightweight ##### Lightweight
- [ ] only javascript dependancy is nostr-tools (TODO: remove timeago)
- [x] connects to the minimum number of relays - [x] connects to the minimum number of relays
- [x] connect relays specified in `10002` or 3 default relays - [x] connect relays specified in `10002` or 3 default relays
- [ ] minimises the number of open websockets - [ ] minimises the number of open websockets

View File

@@ -1,4 +1,5 @@
import { Event, nip05, nip19 } from 'nostr-tools'; import { Event, nip05, nip19 } from 'nostr-tools';
import { sanitize } from 'isomorphic-dompurify';
import { import {
fetchAllCachedProfileEvents, fetchCachedMyProfileEvent, fetchCachedProfileEvent, fetchAllCachedProfileEvents, fetchCachedMyProfileEvent, fetchCachedProfileEvent,
fetchProfileEvent, getContactMostPopularPetname, getContactName, getMyPetnameForUser, fetchProfileEvent, getContactMostPopularPetname, getContactName, getMyPetnameForUser,
@@ -53,12 +54,12 @@ const generateContactDetails = (pubkey:string):string => {
return ` return `
<article> <article>
<div> <div>
${m && !!m.picture ? `<img src="${m.picture}" /> ` : ''} ${m && !!m.picture ? `<img src="${sanitize(m.picture)}" /> ` : ''}
<div class="contactdetailsmain"> <div class="contactdetailsmain">
<strong>${getContactName(pubkey)}</strong> <strong>${getContactName(pubkey)}</strong>
${m.nip05 ? `<small id="nip05-${pubkey}">${m.nip05} </small>` : ''}<span id="nip05-${pubkey}-verified"></span> ${m.nip05 ? `<small id="nip05-${pubkey}">${sanitize(m.nip05)} </small>` : ''}<span id="nip05-${pubkey}-verified"></span>
${otherspetname && otherspetname !== m.name ? `<div>popular petname: ${otherspetname}</div>` : ''} ${otherspetname && otherspetname !== m.name ? `<div>popular petname: ${otherspetname}</div>` : ''}
<div><small>${m.about ? m.about : ''}</small></div> <div><small>${m.about ? sanitize(m.about) : ''}</small></div>
</div> </div>
</div> </div>
<footer class="contactdetailsform"> <footer class="contactdetailsform">

View File

@@ -1,5 +1,6 @@
import * as timeago from 'timeago.js'; import * as timeago from 'timeago.js';
import { Event } from 'nostr-tools'; import { Event } from 'nostr-tools';
import { sanitize } from 'isomorphic-dompurify';
import { fetchCachedMyProfileEventHistory, getContactName, submitUnsignedEvent } from './fetchEvents'; import { fetchCachedMyProfileEventHistory, getContactName, submitUnsignedEvent } from './fetchEvents';
export type VersionChange = { export type VersionChange = {
@@ -26,7 +27,9 @@ export const generateMetadataChanges = (
):VersionChange[] => history.map((e, i, a) => { ):VersionChange[] => history.map((e, i, a) => {
const changes:string[] = []; const changes:string[] = [];
const c = JSON.parse(e.content); const c = JSON.parse(e.content);
const clean = (s:string | number) => (typeof s === 'string' ? s.replace(/(\r\n|\n|\r)/gm, ' ') : s.toString()); const clean = (s:string | number) => (sanitize(
typeof s === 'string' ? s.replace(/(\r\n|\n|\r)/gm, ' ') : s.toString(),
));
// if first backup list all fields and values // if first backup list all fields and values
if (i === a.length - 1) { if (i === a.length - 1) {
Object.keys(c).forEach((k) => changes.push(`${k}: ${clean(c[k])}`)); Object.keys(c).forEach((k) => changes.push(`${k}: ${clean(c[k])}`));

View File

@@ -1,11 +1,12 @@
import { nip05 } from 'nostr-tools'; import { nip05 } from 'nostr-tools';
import { sanitize } from 'isomorphic-dompurify';
import { fetchCachedMyProfileEvent, submitUnsignedEvent } from './fetchEvents'; import { fetchCachedMyProfileEvent, submitUnsignedEvent } from './fetchEvents';
import { loadBackupHistory } from './LoadHistory'; import { loadBackupHistory } from './LoadHistory';
import { localStorageGetItem } from './LocalStorage'; import { localStorageGetItem } from './LocalStorage';
type MetadataCore = { type MetadataCore = {
name: string; name: string;
profile?: string; picture?: string;
about?: string; about?: string;
banner?: string; banner?: string;
nip05?: string; nip05?: string;
@@ -24,7 +25,7 @@ const toTextInput = (prop:string, m:MetadataFlex | null, displayname?:string) =>
type="text" type="text"
name="PM-form-${prop}" name="PM-form-${prop}"
id="PM-form-${prop}" id="PM-form-${prop}"
placeholder="${displayname || prop}" ${m && m[prop] ? `value="${m[prop]}"` : ''} placeholder="${displayname || prop}" ${m && m[prop] ? `value="${sanitize(m[prop] as string)}"` : ''}
/> />
</label> </label>
`; `;
@@ -35,7 +36,7 @@ const toTextarea = (prop:string, m:MetadataFlex | null, displayname?:string) =>
id="PM-form-${prop}" id="PM-form-${prop}"
name="PM-form-${prop}" name="PM-form-${prop}"
placeholder="${displayname || prop}" placeholder="${displayname || prop}"
>${m && m[prop] ? m[prop] : ''}</textarea> >${m && m[prop] ? sanitize(m[prop] as string) : ''}</textarea>
</label> </label>
`; `;
@@ -57,9 +58,9 @@ const generateForm = (c:MetadataFlex | null):string => {
${toTextInput('nip05', c)} ${toTextInput('nip05', c)}
</div> </div>
${toTextarea('about', c)} ${toTextarea('about', c)}
<img id="metadata-form-picture" src="${c && c.picture ? c.picture : ''}"> <img id="metadata-form-picture" src="${c && c.picture ? sanitize(c.picture) : ''}">
${toTextInput('picture', c)} ${toTextInput('picture', c)}
<img id="metadata-form-banner" src="${c && c.banner ? c.banner : ''}"> <img id="metadata-form-banner" src="${c && c.banner ? sanitize(c.banner) : ''}">
${toTextInput('banner', c)} ${toTextInput('banner', c)}
${toTextInput('lud06', c, 'lud06 (LNURL)')} ${toTextInput('lud06', c, 'lud06 (LNURL)')}
${toTextInput('lud16', c)} ${toTextInput('lud16', c)}

View File

@@ -1,4 +1,5 @@
import { Event, UnsignedEvent } from 'nostr-tools'; import { Event, UnsignedEvent } from 'nostr-tools';
import { sanitize } from 'isomorphic-dompurify';
import { localStorageGetItem, localStorageSetItem } from './LocalStorage'; import { localStorageGetItem, localStorageSetItem } from './LocalStorage';
import { publishEventToRelay, requestEventsFromRelays } from './RelayManagement'; import { publishEventToRelay, requestEventsFromRelays } from './RelayManagement';
@@ -210,6 +211,7 @@ export const fetchProfileEvent = async (
return r[0]; return r[0];
}; };
/** returns sanatized most popular petname for contact */
export const getContactMostPopularPetname = (pubkey: string):string | null => { export const getContactMostPopularPetname = (pubkey: string):string | null => {
// considered implementing frank.david.erin model in nip-02 but I think the UX is to confusing // considered implementing frank.david.erin model in nip-02 but I think the UX is to confusing
// get count of petnames for users by other contacts // get count of petnames for users by other contacts
@@ -218,7 +220,7 @@ export const getContactMostPopularPetname = (pubkey: string):string | null => {
.map((pk) => { .map((pk) => {
if (!UserProfileEvents[pk][3]) return null; if (!UserProfileEvents[pk][3]) return null;
const petnametag = UserProfileEvents[pk][3].tags.find((t) => t[1] === pubkey && t[3]); const petnametag = UserProfileEvents[pk][3].tags.find((t) => t[1] === pubkey && t[3]);
if (petnametag) return petnametag[3]; if (petnametag) return sanitize(petnametag[3]);
return null; return null;
}) })
// returns petname counts // returns petname counts
@@ -229,14 +231,17 @@ export const getContactMostPopularPetname = (pubkey: string):string | null => {
}, {} as { [petname: string]: number }); }, {} as { [petname: string]: number });
if (petnamecounts.length === 0) return null; if (petnamecounts.length === 0) return null;
// returns most frequent petname for user amoung contacts (appended with ' (?)') // returns most frequent petname for user amoung contacts (appended with ' (?)')
return Object.keys(petnamecounts).sort((a, b) => petnamecounts[b] - petnamecounts[a])[0]; return sanitize(
Object.keys(petnamecounts).sort((a, b) => petnamecounts[b] - petnamecounts[a])[0],
);
}; };
/** returns my petname for user but sanatized */
export const getMyPetnameForUser = (pubkey: string): string | null => { export const getMyPetnameForUser = (pubkey: string): string | null => {
const e = fetchCachedMyProfileEvent(3); const e = fetchCachedMyProfileEvent(3);
if (e) { if (e) {
const mypetname = e.tags.find((t) => t[1] === pubkey && t[3]); const mypetname = e.tags.find((t) => t[1] === pubkey && t[3]);
if (mypetname) return mypetname[3]; if (mypetname) return sanitize(mypetname[3]);
} }
return null; return null;
}; };
@@ -259,13 +264,14 @@ export const isUserMyContact = (pubkey: string): boolean | null => {
return null; return null;
}; };
/** get sanatized contact name */
export const getContactName = (pubkey: string):string => { export const getContactName = (pubkey: string):string => {
// my own name // my own name
if (localStorageGetItem('pubkey') === pubkey) { if (localStorageGetItem('pubkey') === pubkey) {
const m = fetchCachedMyProfileEvent(0); const m = fetchCachedMyProfileEvent(0);
if (m) { if (m) {
const { name } = JSON.parse(m.content); const { name } = JSON.parse(m.content);
if (name) return name; if (name) return sanitize(name);
} }
} else { } else {
// my petname for contact // my petname for contact
@@ -277,9 +283,9 @@ export const getContactName = (pubkey: string):string => {
if (UserProfileEvents[pubkey][0]) { if (UserProfileEvents[pubkey][0]) {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
const { name, display_name } = JSON.parse(UserProfileEvents[pubkey][0].content); const { name, display_name } = JSON.parse(UserProfileEvents[pubkey][0].content);
if (name) return name; if (name) return sanitize(name);
// name isn't present for Jack Dorsey and Vitor from Amethyst in Apr 2023. // name isn't present for Jack Dorsey and Vitor from Amethyst in Apr 2023.
if (display_name) return display_name; if (display_name) return sanitize(display_name);
} }
} }
} }

5413
yarn.lock

File diff suppressed because it is too large Load Diff