11/**
2- * Conformance runner — fires every `chain` vector at a target verifier endpoint
3- * (which implements CONTRACT.md), SERVER-SIDE, so it works against any URL
4- * regardless of the target's CORS. Returns a live report the microsite renders.
5- *
6- * GET /api/conform?target=https://your-verifier.example/verify
7- *
8- * With no target it defaults to this deployment's own /api/verify-chain (the
9- * reference), so the prefilled "Run" shows a live green result.
10- *
11- * Two hardenings vs the naive version:
12- * - SSRF guard: external targets must be https and must not resolve to a
13- * private/loopback/link-local address (the deployment's own host is exempt).
14- * - Contract detection: distinguishes "this URL never spoke CONTRACT.md"
15- * (not a verifier / errored) from "a verifier that answered wrong".
2+ * GET /api/conform?target=<url> — run the chain vectors against a target
3+ * verifier endpoint (CONTRACT.md), server-side, and return a JSON report.
4+ * Defaults to this deployment's own /api/verify-chain (the reference).
5+ * Core logic + SSRF guard + contract-detection live in ../lib/run-http.ts.
166 */
17- import chainVectors from "../vectors/chain.json" with { type : "json" } ;
18- import { lookup } from "node:dns/promises" ;
19-
20- const CANONICAL_TIME_UNIX = 1780000000 ;
21- const PER_CALL_TIMEOUT_MS = 7000 ;
7+ import { conform } from "../lib/run-http.js" ;
228
239interface Req {
2410 query ?: Record < string , string | string [ ] | undefined > ;
@@ -30,168 +16,12 @@ interface Res {
3016 json ( body : unknown ) : void ;
3117}
3218
33- interface ChainVector {
34- name : string ;
35- chain : string ;
36- kind ?: string ;
37- rootKey ?: Record < string , unknown > ;
38- x5cTrustedRoots ?: string [ ] ;
39- expectedAud : string ;
40- expectedNonce : string ;
41- expect : "valid" | "reject" ;
42- hardening ?: boolean ;
43- expectedPayloads ?: unknown ;
44- }
45-
46- const errMsg = ( e : unknown ) : string => ( e instanceof Error ? e . message : String ( e ) ) ;
4719const one = ( v : string | string [ ] | undefined ) : string | undefined => ( Array . isArray ( v ) ? v [ 0 ] : v ) ;
4820
49- function deepEqual ( a : unknown , b : unknown ) : boolean {
50- if ( a === b ) return true ;
51- if ( typeof a !== typeof b || a === null || b === null || typeof a !== "object" ) return false ;
52- if ( Array . isArray ( a ) !== Array . isArray ( b ) ) return false ;
53- const ka = Object . keys ( a as object ) ;
54- const kb = Object . keys ( b as object ) ;
55- if ( ka . length !== kb . length ) return false ;
56- return ka . every ( ( k ) => deepEqual ( ( a as Record < string , unknown > ) [ k ] , ( b as Record < string , unknown > ) [ k ] ) ) ;
57- }
58-
59- /** True if an IP literal is loopback / private / link-local (incl. cloud metadata). */
60- function isPrivateIp ( addr : string ) : boolean {
61- const a = addr . toLowerCase ( ) . replace ( / ^ \[ | \] $ / g, "" ) ;
62- if ( a === "::1" || a === "::" ) return true ;
63- if ( a . startsWith ( "fe80" ) || a . startsWith ( "fc" ) || a . startsWith ( "fd" ) ) return true ; // v6 link-local / ULA
64- const mapped = a . match ( / : : f f f f : ( \d { 1 , 3 } (?: \. \d { 1 , 3 } ) { 3 } ) $ / ) ;
65- const v4 = mapped ? mapped [ 1 ] : / ^ \d { 1 , 3 } (?: \. \d { 1 , 3 } ) { 3 } $ / . test ( a ) ? a : null ;
66- if ( ! v4 ) return false ;
67- const p = v4 . split ( "." ) . map ( Number ) ;
68- if ( p . length !== 4 || p . some ( ( n ) => Number . isNaN ( n ) || n < 0 || n > 255 ) ) return true ; // malformed → block
69- const [ x , y ] = p ;
70- if ( x === 0 || x === 10 || x === 127 ) return true ;
71- if ( x === 169 && y === 254 ) return true ; // link-local incl. 169.254.169.254 metadata
72- if ( x === 172 && y >= 16 && y <= 31 ) return true ;
73- if ( x === 192 && y === 168 ) return true ;
74- if ( x === 100 && y >= 64 && y <= 127 ) return true ; // CGNAT
75- return false ;
76- }
77-
78- async function classifyTarget ( target : string , selfHost : string ) : Promise < { ok : boolean ; reason ?: string } > {
79- let u : URL ;
80- try {
81- u = new URL ( target ) ;
82- } catch {
83- return { ok : false , reason : "invalid URL" } ;
84- }
85- if ( u . host === selfHost ) return { ok : true } ; // our own reference endpoint — always allowed
86- if ( u . protocol !== "https:" ) return { ok : false , reason : "target must use https" } ;
87- const host = u . hostname . toLowerCase ( ) ;
88- if ( host === "localhost" || host . endsWith ( ".local" ) || host . endsWith ( ".internal" ) ) {
89- return { ok : false , reason : "private host blocked" } ;
90- }
91- if ( isPrivateIp ( host ) ) return { ok : false , reason : "private/loopback IP blocked" } ;
92- try {
93- const addrs = await lookup ( host , { all : true } ) ;
94- if ( addrs . some ( ( a ) => isPrivateIp ( a . address ) ) ) return { ok : false , reason : "host resolves to a private IP" } ;
95- } catch {
96- return { ok : false , reason : "host does not resolve" } ;
97- }
98- return { ok : true } ;
99- }
100-
10121export default async function handler ( req : Req , res : Res ) : Promise < void > {
10222 res . setHeader ( "Access-Control-Allow-Origin" , "*" ) ;
103-
10423 const proto = one ( req . headers ?. [ "x-forwarded-proto" ] ) ?? "https" ;
10524 const host = one ( req . headers ?. [ "host" ] ) ?? "localhost:3000" ;
106- const selfBase = `${ proto } ://${ host } ` ;
107-
108- let target = one ( req . query ?. [ "target" ] ) ?. trim ( ) ;
109- if ( ! target || target === "reference" ) target = `${ selfBase } /api/verify-chain` ;
110- else if ( target . startsWith ( "/" ) ) target = `${ selfBase } ${ target } ` ;
111-
112- const guard = await classifyTarget ( target , host ) ;
113- if ( ! guard . ok ) {
114- return res . status ( 400 ) . json ( {
115- target,
116- status : "BLOCKED" ,
117- error : `target rejected: ${ guard . reason } ` ,
118- endpointSpeaksContract : false ,
119- results : [ ] ,
120- core : { passed : 0 , total : 0 } ,
121- hardening : { passed : 0 , total : 0 } ,
122- conformant : false ,
123- } ) ;
124- }
125-
126- const vectors = chainVectors as ChainVector [ ] ;
127- const results = await Promise . all (
128- vectors . map ( async ( v ) => {
129- const isX5c = v . kind === "x5c" ;
130- const reqBody = {
131- chain : v . chain ,
132- rootKey : isX5c ? null : v . rootKey ,
133- trustedRoots : isX5c ? v . x5cTrustedRoots ?? [ ] : null ,
134- currentTimeUnix : CANONICAL_TIME_UNIX ,
135- expectedAud : v . expectedAud ,
136- expectedNonce : v . expectedNonce ,
137- } ;
138- const profile = v . hardening ? "hardening" : "core" ;
139- let spoke = false ;
140- let passed = false ;
141- let accepted : boolean | null = null ;
142- let detail : string | undefined ;
143- try {
144- const r = await fetch ( target as string , {
145- method : "POST" ,
146- headers : { "content-type" : "application/json" } ,
147- body : JSON . stringify ( reqBody ) ,
148- signal : AbortSignal . timeout ( PER_CALL_TIMEOUT_MS ) ,
149- } ) ;
150- let j : { ok ?: unknown ; payloads ?: unknown ; error ?: string } | null = null ;
151- try {
152- j = ( await r . json ( ) ) as { ok ?: unknown ; payloads ?: unknown ; error ?: string } ;
153- } catch {
154- j = null ;
155- }
156- if ( j && typeof j . ok === "boolean" ) {
157- spoke = true ;
158- accepted = j . ok ;
159- if ( v . expect === "valid" ) {
160- passed = j . ok === true && deepEqual ( j . payloads , v . expectedPayloads ) ;
161- if ( ! passed ) detail = j . ok ? "accepted, but payloads differ from AP2" : `rejected a valid vector (${ j . error ?? "no reason" } )` ;
162- } else {
163- passed = j . ok === false ;
164- if ( ! passed ) detail = "accepted a vector that must be rejected" ;
165- }
166- } else {
167- detail = r . ok ? "response was not the conformance shape { ok, payloads }" : `HTTP ${ r . status } (not the conformance contract)` ;
168- }
169- } catch ( e ) {
170- detail = `request failed: ${ errMsg ( e ) } ` ;
171- }
172- return { category : "chain" , name : v . name , profile, spoke, passed, accepted, detail } ;
173- } ) ,
174- ) ;
175-
176- const spokeCount = results . filter ( ( r ) => r . spoke ) . length ;
177- const endpointSpeaksContract = spokeCount > 0 ;
178- const core = results . filter ( ( r ) => r . profile === "core" ) ;
179- const hard = results . filter ( ( r ) => r . profile === "hardening" ) ;
180- const conformant = endpointSpeaksContract && core . every ( ( r ) => r . passed ) ;
181- const status = ! endpointSpeaksContract ? "DID_NOT_IMPLEMENT_CONTRACT" : conformant ? "CONFORMANT" : "NON_CONFORMANT" ;
182-
183- res . status ( 200 ) . json ( {
184- target,
185- status,
186- endpointSpeaksContract,
187- spokeCount,
188- totalVectors : results . length ,
189- note : endpointSpeaksContract
190- ? "HTTP runner covers the chain category only; the full 67-check suite runs in-process via the adapter."
191- : "This URL never responded with the conformance contract — it isn't an AP2 verifier speaking CONTRACT.md (or it errored / timed out)." ,
192- results,
193- core : { passed : core . filter ( ( r ) => r . passed ) . length , total : core . length } ,
194- hardening : { passed : hard . filter ( ( r ) => r . passed ) . length , total : hard . length } ,
195- conformant,
196- } ) ;
25+ const report = await conform ( one ( req . query ?. [ "target" ] ) , `${ proto } ://${ host } ` , host ) ;
26+ res . status ( report . status === "BLOCKED" ? 400 : 200 ) . json ( report ) ;
19727}
0 commit comments