Skip to content

Commit b8d9207

Browse files
tony8713claude
andcommitted
fix: validate avatar route boundary and thread branded input types
Close the second unvalidated boundary and make validation enforceable by the compiler across the whole resolver call graph. - Validate the GET /:type/:id avatar route inside parseQuery: the stripped, lowercased id must be a valid address or handle (avatarIdSchema), else the route returns HTTP 400 (InvalidQueryError) instead of a 500. The /clear route gets the same 400 path. Size/width/height query clamping is unchanged. - Add branded nominal types in src/helpers/validation.ts: addressSchema / handleSchema now .brand() and export ValidatedAddress / ValidatedHandle, and avatarIdSchema (ValidatedAddress | ValidatedHandle) for the avatar route. All entry-point schemas (JSON-RPC + avatar route) output branded values. - Thread the branded input types through the resolver entry points: lookupAddresses / resolveNames, lookupDomains, getOwner, and the avatar resolver entries (resolvers/ens, resolvers/basename, addressResolvers basename getAvatar / castToEnsName). The compiler now enforces that only validated values reach the resolvers. Plain Address / Handle aliases stay for values flowing OUT of the resolvers (resolved network values are plain). - Drop the now-redundant inner isAddress guard in lookupDomains (shape is guaranteed by the branded Address at the only caller). - Keep normalizeAddresses: its getAddress checksum / lowercase step is NORMALIZATION (required for stable redis cache keys and dedup), not validation, and its reject-filter still guards the unbranded clearCache route param. Cache key shape is unchanged. The batch cap (max 50) stays as a zod .max at the boundary since types cannot encode array length. - Tests: add e2e cases asserting a bad avatar id returns 400 and valid address / handle ids do not; brand integration test inputs via the real schemas (test/helpers/validation.ts); convert the two lookupDomains invalid-input tests to assert the boundary schema rejects them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a021491 commit b8d9207

17 files changed

Lines changed: 268 additions & 81 deletions

File tree

src/addressResolvers/basename.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { namehash } from '@ethersproject/hash';
44
import { capture } from '@snapshot-labs/snapshot-sentry';
55
import snapshot from '@snapshot-labs/snapshot.js';
66
import { FetchError, provider as getProvider, isEvmAddress, isSilencedError } from './utils';
7+
import { addressSchema, AvatarId } from '../helpers/validation';
78
import { Address, EMPTY_ADDRESS, getUrl, Handle } from '../utils';
89

910
export const NAME = 'Basename';
@@ -69,9 +70,12 @@ export async function resolveNames(handles: Handle[]): Promise<Record<Handle, Ad
6970

7071
// Avatar text record, used by the avatar resolver. Resolves the name against
7172
// Base specifically, so an address' ENS primary name can't shadow its Basename.
72-
export async function getAvatar(nameOrAddress: string): Promise<string | null> {
73-
const name = isEvmAddress(nameOrAddress)
74-
? (await lookupAddresses([nameOrAddress]))[nameOrAddress]
73+
export async function getAvatar(nameOrAddress: AvatarId): Promise<string | null> {
74+
// Re-derive the Address brand from the already validated id: when it is an
75+
// address, reverse-resolve to a Basename; otherwise treat it as a handle.
76+
const asAddress = addressSchema.safeParse(nameOrAddress);
77+
const name = asAddress.success
78+
? (await lookupAddresses([asAddress.data]))[asAddress.data]
7579
: normalizeBasename(nameOrAddress);
7680

7781
return name ? getUrl(await call('text', [namehash(name), 'avatar'])) : null;

src/addressResolvers/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
withoutEmptyValues
1616
} from './utils';
1717
import { timeAddressResolverResponse as timeResponse } from '../helpers/metrics';
18+
import { ValidatedAddress, ValidatedHandle } from '../helpers/validation';
1819
import { Address, Handle } from '../utils';
1920

2021
const RESOLVERS = [
@@ -70,7 +71,9 @@ async function _call(fnName: string, input: string[], maxInputLength: number) {
7071
);
7172
}
7273

73-
export async function lookupAddresses(addresses: Address[]): Promise<Record<Address, Handle>> {
74+
export async function lookupAddresses(
75+
addresses: ValidatedAddress[]
76+
): Promise<Record<Address, Handle>> {
7477
const result = await _call(
7578
'lookupAddresses',
7679
Array.from(new Set(normalizeAddresses(addresses))),
@@ -80,7 +83,7 @@ export async function lookupAddresses(addresses: Address[]): Promise<Record<Addr
8083
return mapOriginalInput(addresses, result);
8184
}
8285

83-
export async function resolveNames(handles: Handle[]): Promise<Record<Handle, Address>> {
86+
export async function resolveNames(handles: ValidatedHandle[]): Promise<Record<Handle, Address>> {
8487
const result = await _call(
8588
'resolveNames',
8689
Array.from(new Set(normalizeHandles(handles))),

src/addressResolvers/utils.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,17 @@ export function withoutEmptyAddress(obj: Record<string, any>) {
2929
return Object.fromEntries(Object.entries(obj).filter(([key]) => key !== EMPTY_ADDRESS));
3030
}
3131

32-
export function normalizeAddresses(addresses: Address[]): Address[] {
32+
// NORMALIZATION, not validation. getAddress() checksums EVM addresses and
33+
// Starknet addresses are lowercased so the redis cache keys and the dedup Set
34+
// in src/addressResolvers/index.ts are stable regardless of input casing. This
35+
// step MUST stay even though the branded Address type already guarantees shape
36+
// at the JSON-RPC boundary.
37+
//
38+
// It also still drops anything getAddress() rejects: the clearCache path
39+
// (src/api.ts /clear/address/:id) reaches here with a raw, unbranded route
40+
// param, so this thin reject is the boundary guard for that one path. The
41+
// other callers pass branded Address[] for which the filter is a no-op.
42+
export function normalizeAddresses(addresses: string[]): Address[] {
3343
return addresses
3444
.map(a => {
3545
if (isStarknetAddress(a)) {
@@ -42,8 +52,8 @@ export function normalizeAddresses(addresses: Address[]): Address[] {
4252
.filter(a => a) as Address[];
4353
}
4454

45-
export function normalizeHandles(handles: Handle[]): Handle[] {
46-
return handles.filter(h => /^[^\s]*\.[^\s]*$/.test(h)).map(h => h.toLowerCase());
55+
export function normalizeHandles(handles: string[]): Handle[] {
56+
return handles.filter(h => /^[^\s]*\.[^\s]*$/.test(h)).map(h => h.toLowerCase()) as Handle[];
4757
}
4858

4959
export function isSilencedError(error: any, additionalMessages?: string[]): boolean {

src/api.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ import {
1414
} from './helpers/validation';
1515
import lookupDomains from './lookupDomains';
1616
import resolvers from './resolvers';
17-
import { getCacheKey, parseQuery, resize, ResolverType, setHeader } from './utils';
17+
import {
18+
getCacheKey,
19+
InvalidQueryError,
20+
parseQuery,
21+
resize,
22+
ResolverType,
23+
setHeader
24+
} from './utils';
1825

1926
const router = express.Router();
2027
const TYPE_CONSTRAINTS = [...Object.keys(constants.resolvers), 'address', 'name'].join('|');
@@ -74,6 +81,9 @@ router.get(`/clear/:type(${TYPE_CONSTRAINTS})/:id`, async (req, res) => {
7481
}
7582
res.status(result ? 200 : 404).json({ status: result ? 'ok' : 'not found' });
7683
} catch (err) {
84+
if (err instanceof InvalidQueryError) {
85+
return res.status(400).json({ status: 'error', error: err.message || 'invalid id' });
86+
}
7787
capture(err);
7888
res.status(500).json({ status: 'error', error: 'failed to clear cache' });
7989
}
@@ -89,7 +99,10 @@ router.get(`/:type(${TYPE_CONSTRAINTS})/:id`, async (req, res) => {
8999
type,
90100
req.query
91101
));
92-
} catch {
102+
} catch (err) {
103+
if (err instanceof InvalidQueryError) {
104+
return res.status(400).json({ status: 'error', error: err.message || 'invalid id' });
105+
}
93106
return res.status(500).json({ status: 'error', error: 'failed to load content' });
94107
}
95108

src/getOwner/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { Address, Handle } from '../utils';
1+
import { ValidatedHandle } from '../helpers/validation';
2+
import { Address } from '../utils';
23
import shibarium from './shibarium';
34

4-
export default async function getOwner(handle: Handle, chainId = '1'): Promise<Address> {
5+
export default async function getOwner(handle: ValidatedHandle, chainId = '1'): Promise<Address> {
56
return shibarium(handle, chainId);
67
}

src/helpers/validation.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,66 @@ import { z } from 'zod';
66
export const MAX_LOOKUP_ADDRESSES = 50;
77
export const MAX_RESOLVE_NAMES = 5;
88

9+
// Branded (nominal) types. zod's .brand() makes the parsed output structurally
10+
// distinct from a plain string so the compiler can enforce that only values
11+
// that went through one of these schemas reach the resolvers. There is no
12+
// runtime difference: an `Address` is still a string at runtime.
13+
//
914
// Accepts both EVM addresses (0x + 40 hex chars) and Starknet addresses
1015
// (0x + 64 hex chars), matching isEvmAddress / isStarknetAddress.
11-
const addressString = z
16+
export const addressSchema = z
1217
.string()
1318
.regex(/^0x[a-fA-F0-9]{40}$/, 'must be a valid address')
14-
.or(z.string().regex(/^0x[a-fA-F0-9]{64}$/, 'must be a valid address'));
19+
.or(z.string().regex(/^0x[a-fA-F0-9]{64}$/, 'must be a valid address'))
20+
.brand('Address');
21+
22+
// Branded INPUT type: a string that has been validated as an address by zod.
23+
// Distinct from the plain `Address` value alias in utils.ts so the compiler can
24+
// enforce that the resolver entry points only receive validated input.
25+
export type ValidatedAddress = z.infer<typeof addressSchema>;
26+
27+
// A domain handle (e.g. "vitalik.eth"): a non-empty string containing a dot.
28+
export const handleSchema = z
29+
.string()
30+
.regex(/^[^\s]*\.[^\s]*$/, 'must be a valid handle')
31+
.brand('Handle');
32+
33+
// Branded INPUT type: a string validated as a handle by zod.
34+
export type ValidatedHandle = z.infer<typeof handleSchema>;
1535

1636
export const lookupAddressesSchema = z
17-
.array(addressString)
37+
.array(addressSchema)
1838
.min(1, 'params must contain at least one address')
1939
.max(MAX_LOOKUP_ADDRESSES, `params must contain less than ${MAX_LOOKUP_ADDRESSES} items`);
2040

2141
// resolve_names receives domain handles (e.g. "vitalik.eth"), not addresses.
22-
const handleString = z.string().regex(/^[^\s]*\.[^\s]*$/, 'must be a valid handle');
23-
2442
export const resolveNamesSchema = z
25-
.array(handleString)
43+
.array(handleSchema)
2644
.min(1, 'params must contain at least one name')
2745
.max(MAX_RESOLVE_NAMES, `params must contain less than ${MAX_RESOLVE_NAMES} items`);
2846

29-
// lookup_domains receives a single EVM address string.
47+
// lookup_domains receives a single EVM address string. EVM-only, so it uses a
48+
// narrower regex than the generic address schema (which also accepts Starknet),
49+
// but outputs the same Address brand.
3050
export const lookupDomainsSchema = z
3151
.string()
32-
.regex(/^0x[a-fA-F0-9]{40}$/, 'params must be a valid address');
52+
.regex(/^0x[a-fA-F0-9]{40}$/, 'params must be a valid address')
53+
.brand('Address');
3354

3455
// get_owner receives a single domain handle string.
35-
export const getOwnerSchema = z.string().regex(/^[^\s]*\.[^\s]*$/, 'params must be a valid handle');
56+
export const getOwnerSchema = z
57+
.string()
58+
.regex(/^[^\s]*\.[^\s]*$/, 'params must be a valid handle')
59+
.brand('Handle');
60+
61+
// Avatar image route (GET /:type/:id). The `id`, once stripped of any chain
62+
// prefix (eip3770 "eth:0x..", caip10 "eip155:1:0x..", "did:..") and lowercased,
63+
// must be either a valid address or a valid handle/name. Address callers feed
64+
// the address resolvers (which require a branded Address), so this either-or is
65+
// what threads the brand into the avatar path.
66+
export const avatarIdSchema = z.union([addressSchema, handleSchema]);
67+
68+
export type AvatarId = z.infer<typeof avatarIdSchema>;
3669

3770
// Produces a short human readable summary of a ZodError, suitable for the
3871
// JSON-RPC error `data` field or an HTTP 400 message.

src/lookupDomains/index.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { isAddress } from '@ethersproject/address';
2-
import { Address, Handle } from '../utils';
1+
import { ValidatedAddress } from '../helpers/validation';
2+
import { Handle } from '../utils';
33
import ens, { DEFAULT_CHAIN_ID as ENS_DEFAULT_CHAIN_ID } from './ens';
44
import shibarium, { DEFAULT_CHAIN_ID as SHIBARIUM_DEFAULT_CHAIN_ID } from './shibarium';
55
import unstoppableDomains, {
@@ -15,14 +15,16 @@ const DEFAULT_CHAIN_IDS = [
1515
];
1616

1717
export default async function lookupDomains(
18-
address: Address,
18+
address: ValidatedAddress,
1919
chains: string | string[] = DEFAULT_CHAIN_IDS
2020
): Promise<Handle[]> {
2121
const promises: Promise<Handle[]>[] = [];
2222
let chainIds = Array.isArray(chains) ? chains : [chains];
2323
chainIds = [...new Set(chainIds.map(String))];
2424

25-
if (!isAddress(address)) return [];
25+
// The address shape is now guaranteed by the branded Address type: the only
26+
// caller (src/api.ts lookup_domains) validates it with lookupDomainsSchema at
27+
// the boundary, so the previous runtime isAddress guard is redundant.
2628

2729
RESOLVERS.forEach(resolver => {
2830
chainIds.forEach(chain => {

src/resolvers/basename.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { getAvatar } from '../addressResolvers/basename';
22
import { max } from '../constants.json';
33
import { resize } from '../utils';
44
import { fetchHttpImage } from './utils';
5+
import { AvatarId } from '../helpers/validation';
56

6-
export default async function resolve(nameOrAddress: string) {
7+
export default async function resolve(nameOrAddress: AvatarId) {
78
try {
89
const url = await getAvatar(nameOrAddress);
910
if (!url) return false;

src/resolvers/ens.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
1-
import { isAddress } from '@ethersproject/address';
21
import { max } from '../constants.json';
32
import { getProvider, resize } from '../utils';
43
import { fetchHttpImage } from './utils';
54
import { lookupAddresses } from '../addressResolvers';
6-
7-
async function castToEnsName(nameOrAddress: string): Promise<string | undefined> {
8-
if (isAddress(nameOrAddress)) {
9-
return (await lookupAddresses([nameOrAddress]))[nameOrAddress];
5+
import { addressSchema, AvatarId } from '../helpers/validation';
6+
7+
async function castToEnsName(nameOrAddress: AvatarId): Promise<string | undefined> {
8+
// Re-derive the Address brand from the already validated id: when it is an
9+
// address, feed the branded value into lookupAddresses; otherwise it is a
10+
// handle and used as-is.
11+
const asAddress = addressSchema.safeParse(nameOrAddress);
12+
if (asAddress.success) {
13+
return (await lookupAddresses([asAddress.data]))[asAddress.data];
1014
}
1115

1216
return nameOrAddress;
1317
}
1418

15-
export default async function resolve(nameOrAddress: string) {
19+
export default async function resolve(nameOrAddress: AvatarId) {
1620
try {
1721
const provider = getProvider(1);
1822
const ensName = await castToEnsName(nameOrAddress);

src/utils.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,17 @@ import { Response } from 'express';
66
import sharp from 'sharp';
77
import chains from './chains.json';
88
import constants from './constants.json';
9+
import { AvatarId, avatarIdSchema, formatZodError } from './helpers/validation';
910

11+
// Thrown by parseQuery when the route `id` is neither a valid address nor a
12+
// valid handle. The route handler maps this to an HTTP 400.
13+
export class InvalidQueryError extends Error {}
14+
15+
// Plain value-level aliases for addresses and handles as they flow THROUGH and
16+
// OUT of the resolvers (resolved values come back from the network as ordinary
17+
// strings). The branded, validated INPUT types live in helpers/validation.ts
18+
// (ValidatedAddress / ValidatedHandle / AvatarId) and are what the entry-point
19+
// functions accept, so only values that passed a zod schema can reach them.
1020
export type Address = string;
1121
export type Handle = string;
1222
export type ResolverType =
@@ -92,6 +102,17 @@ export async function parseQuery(id: string, type: ResolverType, query) {
92102
// console.log('Format', format);
93103

94104
address = address.toLowerCase();
105+
106+
// Validate the resolver input at this REST boundary (the JSON-RPC boundary in
107+
// src/api.ts is validated separately). The stripped id must be a valid
108+
// address or handle; anything else is a 400, not a 500. The parsed value is
109+
// branded (AvatarId) so the address path can thread it into the resolvers.
110+
const parsedId = avatarIdSchema.safeParse(address);
111+
if (!parsedId.success) {
112+
throw new InvalidQueryError(formatZodError(parsedId.error));
113+
}
114+
const validatedAddress: AvatarId = parsedId.data;
115+
95116
const size = 64;
96117
const maxSize = type.includes('-cover') ? constants.maxCover : constants.max;
97118
let s = query.s ? parseInt(query.s) : size;
@@ -102,7 +123,7 @@ export async function parseQuery(id: string, type: ResolverType, query) {
102123
if (h < 1 || h > maxSize || isNaN(h)) h = size;
103124

104125
return {
105-
address,
126+
address: validatedAddress,
106127
network,
107128
networkId,
108129
w,

0 commit comments

Comments
 (0)