Audit fixes: harden Amber signEvent response handling, fix web AmberSigner signEvent naming, add Blossom upload fallbacks, sanitize untrusted URLs in FoodPage/CreateTourPage

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 20:20:08 +07:00
parent 5017c1b7ff
commit 5e8544316b
5 changed files with 70 additions and 10 deletions

View file

@ -13,8 +13,13 @@ export function useUploadFile() {
} }
const uploader = new BlossomUploader({ const uploader = new BlossomUploader({
// Multiple servers so a single slow/down Blossom host doesn't hang the
// upload (crew logo uploads were stalling when primal.net was slow).
// The uploader tries them in order and falls back on failure.
servers: [ servers: [
'https://blossom.primal.net/', 'https://blossom.primal.net/',
'https://blossom.band/',
'https://cdn.satellite.earth/',
], ],
signer: user.signer, signer: user.signer,
}); });

View file

@ -604,11 +604,17 @@ export class AmberSigner implements NostrSigner {
return this.pubkey; return this.pubkey;
} }
async sign(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> { // NOTE: Nostrify's NostrSigner interface requires `signEvent` (not `sign`).
// The old method was named `sign`, so every `user.signer.signEvent(...)` call
// on a web/PWA Amber login threw "signer.signEvent is not a function". This
// now correctly implements the interface. On web, signing genuinely requires
// a redirect round-trip to Amber, so this navigates away and the result is
// processed on return by AmberCallbackPage.
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
// Store the current state and redirect to Amber // Store the current state and redirect to Amber
// The result will be processed on page reload // The result will be processed on page reload
openAmberForSignEvent(event); openAmberForSignEvent(event);
// This promise will never resolve in the current page context // This promise will never resolve in the current page context
// because we're redirecting to Amber // because we're redirecting to Amber
return new Promise(() => { return new Promise(() => {

View file

@ -202,7 +202,7 @@ export class CapacitorAmberSigner implements NostrSigner {
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> { async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
const plugin = await getPlugin(); const plugin = await getPlugin();
const { event: signedJson } = await withTimeout( const res = await withTimeout(
plugin.signEvent({ plugin.signEvent({
packageName: AMBER_PACKAGE, packageName: AMBER_PACKAGE,
eventJson: JSON.stringify(event), eventJson: JSON.stringify(event),
@ -211,7 +211,54 @@ export class CapacitorAmberSigner implements NostrSigner {
}), }),
'signature', 'signature',
); );
return JSON.parse(signedJson) as NostrEvent;
// Amber's response shape varies across builds: some return the full signed
// event JSON in `event`, others return only `signature` (expecting the caller
// to attach it to the original event). If we blindly `JSON.parse` an empty
// `event`, or return an event with no `sig`, the resulting object silently
// publishes as a malformed event and stalls the relay forever — the exact
// "publish hangs" symptom. Handle every shape defensively and always fail
// loud rather than emitting a bad event.
let signed: Partial<NostrEvent> | null = null;
if (res.event && res.event.trim()) {
try {
signed = JSON.parse(res.event) as Partial<NostrEvent>;
} catch {
signed = null;
}
}
// Fall back to reconstructing the event from a bare signature.
if ((!signed || !signed.sig) && res.signature) {
signed = { ...(signed ?? {}), sig: res.signature };
}
if (!signed || !signed.sig) {
throw new Error(
'Amber returned an incomplete signature. Open Amber, approve the request, then try again.',
);
}
// Merge Amber's response over the original event so required fields are
// never lost, then verify the essentials are present before returning.
const merged: NostrEvent = {
kind: signed.kind ?? event.kind,
content: signed.content ?? event.content,
tags: signed.tags ?? event.tags,
created_at: signed.created_at ?? event.created_at,
pubkey: signed.pubkey ?? this.pubkey,
id: signed.id ?? '',
sig: signed.sig,
};
if (!merged.id || !merged.sig || !merged.pubkey) {
throw new Error(
'Amber returned a malformed event. Please retry, or restart Amber if it persists.',
);
}
return merged;
} }
readonly nip04 = { readonly nip04 = {

View file

@ -14,6 +14,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUploadFile } from '@/hooks/useUploadFile'; import { useUploadFile } from '@/hooks/useUploadFile';
import { useShops } from '@/hooks/useShops'; import { useShops } from '@/hooks/useShops';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { FeaturedUpgrade } from '@/components/FeaturedEventUpgrade'; import { FeaturedUpgrade } from '@/components/FeaturedEventUpgrade';
@ -1811,9 +1812,9 @@ export default function CreateTourPage() {
</p> </p>
)} )}
<div className="flex items-center gap-2 mt-1 flex-wrap"> <div className="flex items-center gap-2 mt-1 flex-wrap">
{shop.website && ( {shop.website && sanitizeUrl(shop.website) && (
<a <a
href={shop.website} href={sanitizeUrl(shop.website)!}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs text-cyan-400 hover:text-cyan-300" className="text-xs text-cyan-400 hover:text-cyan-300"

View file

@ -26,6 +26,7 @@ import {
ChefHat, ChefHat,
} from 'lucide-react'; } from 'lucide-react';
import type { FoodSpot } from '@/lib/types'; import type { FoodSpot } from '@/lib/types';
import { sanitizeUrl, sanitizeTel } from '@/lib/sanitizeUrl';
const FOOD_TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = { const FOOD_TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
restaurant: ChefHat, restaurant: ChefHat,
@ -105,16 +106,16 @@ function FoodSpotCard({ spot }: { spot: FoodSpot }) {
)} )}
<div className="flex items-center gap-2 pt-2"> <div className="flex items-center gap-2 pt-2">
{spot.phone && ( {spot.phone && sanitizeTel(spot.phone) && (
<a href={`tel:${spot.phone}`}> <a href={sanitizeTel(spot.phone)!}>
<Button variant="outline" size="sm" className="gap-2"> <Button variant="outline" size="sm" className="gap-2">
<Phone className="h-3 w-3" /> <Phone className="h-3 w-3" />
Call Call
</Button> </Button>
</a> </a>
)} )}
{spot.website && ( {spot.website && sanitizeUrl(spot.website) && (
<a href={spot.website} target="_blank" rel="noopener noreferrer"> <a href={sanitizeUrl(spot.website)!} target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-2"> <Button variant="outline" size="sm" className="gap-2">
<Globe className="h-3 w-3" /> <Globe className="h-3 w-3" />
Website Website