Address review feedback on Relay Admin app

- sanitizeIconUrl() now only allows http:// for local relay hostnames
  (localhost/127.0.0.1/::1/*.local), matching its docstring and error
  message instead of accepting arbitrary http:// URLs.
- Scope the discovery-time cache clear to the previous session's own
  relay URL instead of removing every ['nip86'] query, so connecting in
  one Relay Admin window no longer disrupts other open windows.
- Use the AllowedPubkey type (not BannedPubkey) when mapping allowed-
  pubkey entries, since the structural overlap today made a real type
  mismatch invisible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
This commit is contained in:
2026-09-07 22:05:06 +02:00
parent e169b5e14e
commit 8c249cff83
4 changed files with 23 additions and 6 deletions

View File

@@ -18,6 +18,7 @@ import {
parsePubkeyInput,
validateIpInput,
validateReason,
type AllowedPubkey,
type BannedPubkey,
type BlockedIp,
type Nip86CoreMethod,
@@ -400,7 +401,7 @@ export function AllowedPubkeysSection({
/>
) : (
<ul className="divide-y divide-border border-t border-border">
{filtered.map((entry: BannedPubkey) => (
{filtered.map((entry: AllowedPubkey) => (
<PolicyRow
key={entry.pubkey}
id={entry.pubkey}

View File

@@ -191,6 +191,12 @@ export function useNip86Connection(): Nip86Connection {
}
const { core, extensions } = partitionMethods(methodsResult);
// A different relay must never show the previous relay's policy lists
// — but query keys are already scoped by URL, so only this session's
// own prior relay (not every open Relay Admin window) needs clearing.
if (session && session.url !== url) {
queryClient.removeQueries({ queryKey: ['nip86', session.url] });
}
setSession({
url,
info,
@@ -198,8 +204,6 @@ export function useNip86Connection(): Nip86Connection {
extensions,
canListRoles: methodsResult.includes('listroles'),
});
// A different relay must never show the previous relay's policy lists.
queryClient.removeQueries({ queryKey: ['nip86'] });
record({
method: 'supportedmethods',
target: 'discovery',
@@ -223,7 +227,7 @@ export function useNip86Connection(): Nip86Connection {
setIsConnecting(false);
}
},
[user, queryClient, record],
[user, queryClient, record, session],
);
return {

View File

@@ -182,6 +182,12 @@ describe('input validation', () => {
expect(sanitizeIconUrl('not a url')).toHaveProperty('error');
});
it('only allows http:// for local relays', () => {
expect(sanitizeIconUrl('http://localhost:4869/icon.png')).toBe('http://localhost:4869/icon.png');
expect(sanitizeIconUrl('http://127.0.0.1/icon.png')).toBe('http://127.0.0.1/icon.png');
expect(sanitizeIconUrl('http://relay.example.com/icon.png')).toHaveProperty('error');
});
it('caps reason length', () => {
expect(validateReason('spam')).toBeUndefined();
expect(validateReason('x'.repeat(501))).toBeTruthy();

View File

@@ -549,6 +549,12 @@ export function validateRoleColor(input: string): string | undefined {
: 'Use a hex color like #8b5cf6, or leave it empty.';
}
/** Loopback/`.local` hostnames — the only ones http:// is trusted for below. */
function isLocalHostname(hostname: string): boolean {
const host = hostname.toLowerCase();
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.local');
}
/**
* Validate and sanitize a relay icon URL before it is shown or submitted.
* Only https (and http for local relays) URLs survive — anything else could
@@ -559,8 +565,8 @@ export function sanitizeIconUrl(input: string): string | { error: string } {
if (!value) return { error: 'Enter an icon URL.' };
try {
const parsed = new URL(value);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return { error: 'Only https:// icon URLs are allowed.' };
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && isLocalHostname(parsed.hostname))) {
return { error: 'Only https:// icon URLs are allowed (http:// only for local relays).' };
}
return parsed.href;
} catch {