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({
// 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: [
'https://blossom.primal.net/',
'https://blossom.band/',
'https://cdn.satellite.earth/',
],
signer: user.signer,
});

View file

@ -604,11 +604,17 @@ export class AmberSigner implements NostrSigner {
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
// The result will be processed on page reload
openAmberForSignEvent(event);
// This promise will never resolve in the current page context
// because we're redirecting to Amber
return new Promise(() => {

View file

@ -202,7 +202,7 @@ export class CapacitorAmberSigner implements NostrSigner {
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
const plugin = await getPlugin();
const { event: signedJson } = await withTimeout(
const res = await withTimeout(
plugin.signEvent({
packageName: AMBER_PACKAGE,
eventJson: JSON.stringify(event),
@ -211,7 +211,54 @@ export class CapacitorAmberSigner implements NostrSigner {
}),
'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 = {

View file

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

View file

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