Skip to content

Commit de7c15d

Browse files
authored
Merge pull request #2 from base/feat-priv-670-add-trait-validator
feat(PRIV-670): add trait validator
2 parents e30fb57 + 6ea4c2c commit de7c15d

6 files changed

Lines changed: 310 additions & 1 deletion

File tree

docs/api.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ Check if a wallet has a specific verification and retrieve the verification toke
2424

2525
**Important:** This endpoint must only be called from your backend. Never expose your secret key in frontend code.
2626

27+
**Security Requirement:** Before calling this endpoint, your backend **MUST** validate that the trait requirements in the SIWE message match what your backend expects. See [Security Best Practices](/docs/security#validate-trait-requirements) for details.
28+
2729
### Request
2830

2931
```ts
@@ -33,6 +35,33 @@ Check if a wallet has a specific verification and retrieve the verification toke
3335
}
3436
```
3537

38+
### Backend Validation Required
39+
40+
Before forwarding the request to Base Verify, validate trait requirements:
41+
42+
```typescript
43+
import { validateTraits } from './lib/trait-validator';
44+
45+
// Define expected traits (must match frontend)
46+
const expectedTraits = {
47+
'verified': 'true',
48+
'followers': 'gte:1000'
49+
};
50+
51+
// Validate message contains expected traits
52+
const validation = validateTraits(message, 'x', expectedTraits);
53+
54+
if (!validation.valid) {
55+
return res.status(400).json({
56+
error: 'Invalid trait requirements in message'
57+
});
58+
}
59+
60+
// Safe to forward to Base Verify API
61+
```
62+
63+
This prevents users from modifying trait requirements on the frontend to bypass your access controls.
64+
3665
### Example Request
3766

3867
```bash

docs/security.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,48 @@ Users can delete their verifications at any time:
8282

8383
## Best Practices
8484

85+
### Validate Trait Requirements
86+
87+
**Critical Security Requirement:**
88+
89+
When your backend receives a SIWE message from the frontend, you **MUST** validate that the trait requirements in the message match what your backend expects. This prevents users from modifying trait requirements on the frontend to bypass your access controls.
90+
91+
**Example Attack Without Validation:**
92+
1. Your app requires users to have 100 followers
93+
2. User modifies the frontend to request only 10 followers
94+
3. User signs the modified message
95+
4. Without validation, your backend forwards the request to Base Verify
96+
5. User gains access with less than 100 followers ❌
97+
98+
**Implementation:**
99+
100+
```typescript
101+
import { validateTraits } from './lib/trait-validator';
102+
103+
// Define what traits your app requires
104+
const expectedTraits = {
105+
'followers': 'gte:100'
106+
};
107+
108+
// Validate before forwarding to Base Verify
109+
const validation = validateTraits(message, 'x', expectedTraits);
110+
111+
if (!validation.valid) {
112+
return res.status(400).json({
113+
error: 'Invalid trait requirements in message',
114+
details: validation.error
115+
});
116+
}
117+
118+
// Now safe to forward to Base Verify API
119+
```
120+
121+
**Key Points:**
122+
- Trait requirements in SIWE message are embedded as URNs (e.g., `urn:verify:provider:x:followers:gte:100`)
123+
- Users can modify these before signing
124+
- Backend must validate they match expected requirements
125+
- Validation must happen **before** calling Base Verify API
126+
85127
### Protect Your Secret Key
86128

87129
**Never:**

lib/trait-validator.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/**
2+
* Trait Validation Utility
3+
*
4+
* Validates that SIWE message trait requirements match backend expectations.
5+
* This prevents users from modifying trait requirements on the frontend.
6+
*/
7+
8+
export interface TraitRequirement {
9+
[traitName: string]: string; // e.g., { 'verified': 'true', 'followers': 'gte:1000' }
10+
}
11+
12+
export interface ValidationResult {
13+
valid: boolean;
14+
error?: string;
15+
provider?: string;
16+
foundTraits?: TraitRequirement;
17+
expectedTraits?: TraitRequirement;
18+
}
19+
20+
/**
21+
* Parse trait URNs from SIWE message resources
22+
* Format: urn:verify:provider:{provider}:{trait_name}:{operation}:{value}
23+
*
24+
* @param message - The SIWE message string
25+
* @returns Object with provider and traits, or null if no provider found
26+
*/
27+
function parseTraitsFromMessage(message: string): { provider: string; traits: TraitRequirement } | null {
28+
// Extract resources section from SIWE message
29+
const resourcesMatch = message.match(/Resources:\s*\n((?:- .+\n)+)/);
30+
if (!resourcesMatch) {
31+
return null;
32+
}
33+
34+
const resources = resourcesMatch[1]
35+
.split('\n')
36+
.map(line => line.replace(/^- /, '').trim())
37+
.filter(line => line.length > 0);
38+
39+
// Find provider URN
40+
const providerUrn = resources.find(r => r.match(/^urn:verify:provider:[^:]+$/));
41+
if (!providerUrn) {
42+
return null;
43+
}
44+
45+
const providerMatch = providerUrn.match(/^urn:verify:provider:([^:]+)$/);
46+
if (!providerMatch) {
47+
return null;
48+
}
49+
50+
const provider = providerMatch[1];
51+
const traits: TraitRequirement = {};
52+
53+
// Parse trait URNs for this provider
54+
const traitPattern = new RegExp(`^urn:verify:provider:${provider}:([^:]+):([^:]+):(.+)$`);
55+
56+
resources.forEach(resource => {
57+
const traitMatch = resource.match(traitPattern);
58+
if (traitMatch) {
59+
const [, traitName, operation, value] = traitMatch;
60+
61+
// Reconstruct the trait value with operation prefix
62+
// (except for 'eq' which is the default and can be omitted)
63+
if (operation === 'eq') {
64+
traits[traitName] = value;
65+
} else {
66+
traits[traitName] = `${operation}:${value}`;
67+
}
68+
}
69+
});
70+
71+
return { provider, traits };
72+
}
73+
74+
/**
75+
* Normalize trait requirement for comparison
76+
* Handles the 'eq' operation being implicit
77+
*
78+
* @param value - The trait value (e.g., 'true', 'gte:1000')
79+
* @returns Normalized value with explicit operation
80+
*/
81+
function normalizeTraitValue(value: string): string {
82+
// If value doesn't have an operation prefix, it's implicitly 'eq'
83+
if (!value.match(/^(eq|gt|gte|lt|lte|in):/)) {
84+
return `eq:${value}`;
85+
}
86+
return value;
87+
}
88+
89+
/**
90+
* Validate that message traits match expected requirements
91+
*
92+
* @param message - The SIWE message string
93+
* @param expectedProvider - Expected provider name (e.g., 'x', 'coinbase')
94+
* @param expectedTraits - Expected trait requirements
95+
* @returns ValidationResult with validation status and details
96+
*/
97+
export function validateTraits(
98+
message: string,
99+
expectedProvider: string,
100+
expectedTraits: TraitRequirement
101+
): ValidationResult {
102+
// Parse traits from message
103+
const parsed = parseTraitsFromMessage(message);
104+
105+
if (!parsed) {
106+
return {
107+
valid: false,
108+
error: `No provider found in SIWE message`,
109+
expectedTraits
110+
};
111+
}
112+
113+
const { provider, traits: foundTraits } = parsed;
114+
115+
// Check provider matches
116+
if (provider !== expectedProvider) {
117+
return {
118+
valid: false,
119+
error: `Provider mismatch: expected '${expectedProvider}', found '${provider}'`,
120+
provider,
121+
foundTraits,
122+
expectedTraits
123+
};
124+
}
125+
126+
// Normalize all trait values for comparison
127+
const normalizedExpected: Record<string, string> = {};
128+
Object.entries(expectedTraits).forEach(([key, value]) => {
129+
normalizedExpected[key] = normalizeTraitValue(value);
130+
});
131+
132+
const normalizedFound: Record<string, string> = {};
133+
Object.entries(foundTraits).forEach(([key, value]) => {
134+
normalizedFound[key] = normalizeTraitValue(value);
135+
});
136+
137+
// Check all expected traits are present with correct values
138+
const missingTraits: string[] = [];
139+
const mismatchedTraits: Array<{ trait: string; expected: string; found: string }> = [];
140+
141+
Object.entries(normalizedExpected).forEach(([traitName, expectedValue]) => {
142+
if (!(traitName in normalizedFound)) {
143+
missingTraits.push(traitName);
144+
} else if (normalizedFound[traitName] !== expectedValue) {
145+
mismatchedTraits.push({
146+
trait: traitName,
147+
expected: expectedValue,
148+
found: normalizedFound[traitName]
149+
});
150+
}
151+
});
152+
153+
// Check for unexpected traits (traits in message but not in expected)
154+
const unexpectedTraits = Object.keys(normalizedFound).filter(
155+
key => !(key in normalizedExpected)
156+
);
157+
158+
// Build error message if validation fails
159+
if (missingTraits.length > 0 || mismatchedTraits.length > 0 || unexpectedTraits.length > 0) {
160+
const errors: string[] = [];
161+
162+
if (missingTraits.length > 0) {
163+
errors.push(`Missing required traits: ${missingTraits.join(', ')}`);
164+
}
165+
166+
if (mismatchedTraits.length > 0) {
167+
const details = mismatchedTraits
168+
.map(({ trait, expected, found }) => `${trait} (expected: ${expected}, found: ${found})`)
169+
.join('; ');
170+
errors.push(`Trait value mismatch: ${details}`);
171+
}
172+
173+
if (unexpectedTraits.length > 0) {
174+
errors.push(`Unexpected traits: ${unexpectedTraits.join(', ')}`);
175+
}
176+
177+
return {
178+
valid: false,
179+
error: errors.join('. '),
180+
provider,
181+
foundTraits,
182+
expectedTraits
183+
};
184+
}
185+
186+
// All checks passed
187+
return {
188+
valid: true,
189+
provider,
190+
foundTraits,
191+
expectedTraits
192+
};
193+
}
194+

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,6 @@
4343
"eslint-config-next": "15.0.3",
4444
"prisma": "^5.20.0",
4545
"typescript": "^5.4.5"
46-
}
46+
},
47+
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
4748
}

pages/api/coinbase/verify-token.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextApiRequest, NextApiResponse } from 'next';
22
import { config } from '../../../lib/config';
33
import prisma from '../../../lib/prisma';
4+
import { validateTraits } from '../../../lib/trait-validator';
45

56
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
67
if (req.method !== 'POST') {
@@ -17,6 +18,27 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
1718
});
1819
}
1920

21+
// Define expected trait requirements for Coinbase verification
22+
// This MUST match what the frontend generates
23+
const expectedTraits = {
24+
'coinbase_one_active': 'true',
25+
'country': 'in:US,CA,MX'
26+
};
27+
28+
// Validate that the message contains the expected trait requirements
29+
const validation = validateTraits(message, 'coinbase', expectedTraits);
30+
31+
if (!validation.valid) {
32+
console.error('Trait validation failed:', validation.error);
33+
console.error('Expected traits:', validation.expectedTraits);
34+
console.error('Found traits:', validation.foundTraits);
35+
36+
return res.status(400).json({
37+
error: 'Invalid trait requirements in message',
38+
details: validation.error
39+
});
40+
}
41+
2042
const requestBody: any = {
2143
signature,
2244
message

pages/api/verify-token.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextApiRequest, NextApiResponse } from 'next';
22
import { config } from '../../lib/config';
33
import prisma from '../../lib/prisma';
4+
import { validateTraits } from '../../lib/trait-validator';
45

56
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
67
if (req.method !== 'POST') {
@@ -17,6 +18,26 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
1718
});
1819
}
1920

21+
// Define expected trait requirements for X verification
22+
// This MUST match what the frontend generates
23+
const expectedTraits = {
24+
'verified': 'true'
25+
};
26+
27+
// Validate that the message contains the expected trait requirements
28+
const validation = validateTraits(message, 'x', expectedTraits);
29+
30+
if (!validation.valid) {
31+
console.error('Trait validation failed:', validation.error);
32+
console.error('Expected traits:', validation.expectedTraits);
33+
console.error('Found traits:', validation.foundTraits);
34+
35+
return res.status(400).json({
36+
error: 'Invalid trait requirements in message',
37+
details: validation.error
38+
});
39+
}
40+
2041
const requestBody: any = {
2142
signature,
2243
message

0 commit comments

Comments
 (0)