@ -32,6 +32,29 @@ export function parseBalanceResponse(raw: string): BalanceResult {
return { ok : false , balance : null , message : ` Could not read a balance from: ${ text . slice ( 0 , 80 ) } ` }
}
/ * *
* SSRF guard : the balance URL is an admin - set setting and we send client credentials to
* it , so refuse loopback / link - local / private targets ( e . g . cloud metadata 169.254 . 169.254 ,
* localhost , RFC - 1918 ) . A literal private IP or an internal - looking name throws ; public hosts
* pass . Throws a message that NEVER contains the credential - bearing URL . Exported for tests .
* /
export function assertSafeGatewayUrl ( base : string ) : void {
let u : URL
try { u = new URL ( base ) } catch { throw new Error ( 'Invalid balance API URL' ) }
if ( u . protocol !== 'http:' && u . protocol !== 'https:' ) throw new Error ( 'Balance API URL must be http or https' )
const host = u . hostname . toLowerCase ( ) . replace ( /^\[|\]$/g , '' )
if ( host === 'localhost' || host === '::1' || host === '::' || host . endsWith ( '.local' ) || host . endsWith ( '.internal' ) ) {
throw new Error ( 'Balance API host not allowed (internal)' )
}
const m = host . match ( /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/ )
if ( m !== null ) {
const a = Number ( m [ 1 ] ! ) , b = Number ( m [ 2 ] ! )
if ( a === 0 || a === 127 || a === 10 || ( a === 169 && b === 254 ) || ( a === 192 && b === 168 ) || ( a === 172 && b >= 16 && b <= 31 ) ) {
throw new Error ( 'Balance API host not allowed (private/loopback)' )
}
}
}
/** Build the request URL, substituting the account credentials into the configured base. */
export function balanceUrl ( base : string , uname : string , pass : string ) : string {
const u = new URL ( base )
@ -41,20 +64,28 @@ export function balanceUrl(base: string, uname: string, pass: string): string {
}
/ * *
* Call the gateway for one account ' s balance . Network + parse ; a timeout or non - 200
* becomes a failed BalanceResult ( never throws ) so a bad account can ' t abort a batch .
* Call the gateway for one account ' s balance . Network + parse ; a timeout , redirect , or
* non - 200 becomes a failed BalanceResult ( never throws ) so a bad account can ' t abort a
* batch . Redirects are NOT followed and the credential - bearing URL is never put into any
* returned message ( it would leak the account password to logs / callers ) .
* /
export async function fetchSmsBalance ( base : string , uname : string , pass : string ) : Promise < BalanceResult > {
let url : string
try { url = balanceUrl ( base , uname , pass ) } catch { return { ok : false , balance : null , message : 'Invalid balance API URL' } }
try { assertSafeGatewayUrl ( base ) ; url = balanceUrl ( base , uname , pass ) }
catch ( e ) { return { ok : false , balance : null , message : e instanceof Error ? e . message : 'Invalid balance API URL' } }
try {
const ctrl = new AbortController ( )
const t = setTimeout ( ( ) = > ctrl . abort ( ) , 12 _000 )
const resp = await fetch ( url , { signal : ctrl.signal } ) . finally ( ( ) = > clearTimeout ( t ) )
// redirect:'manual' — a 3xx must not carry the credentials off to another host.
const resp = await fetch ( url , { signal : ctrl.signal , redirect : 'manual' } ) . finally ( ( ) = > clearTimeout ( t ) )
if ( resp . type === 'opaqueredirect' || ( resp . status >= 300 && resp . status < 400 ) ) {
return { ok : false , balance : null , message : 'Gateway redirected — not followed (credential safety)' }
}
if ( ! resp . ok ) return { ok : false , balance : null , message : ` Gateway HTTP ${ resp . status } ` }
return parseBalanceResponse ( await resp . text ( ) )
} catch ( err ) {
const msg = err instanceof Error ? err.message : String ( err )
return { ok : false , balance : null , message : ` Gateway unreachable: ${ msg } ` }
// Never interpolate the URL/error verbatim — it can carry the credential query string.
const aborted = err instanceof Error && err . name === 'AbortError'
return { ok : false , balance : null , message : aborted ? 'Gateway timed out' : 'Gateway unreachable' }
}
}