@@ -26,7 +26,10 @@ import {
2626 validateSupabaseProjectId ,
2727 validateWorkdayTenantUrl ,
2828} from '@/lib/core/security/input-validation'
29- import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
29+ import {
30+ isPrivateOrReservedIP ,
31+ validateUrlWithDNS ,
32+ } from '@/lib/core/security/input-validation.server'
3033import { sanitizeForLogging } from '@/lib/core/security/redaction'
3134
3235vi . mock ( '@/lib/core/config/feature-flags' , ( ) => featureFlagsMock )
@@ -562,6 +565,147 @@ describe('sanitizeForLogging', () => {
562565 } )
563566} )
564567
568+ describe ( 'isPrivateOrReservedIP' , ( ) => {
569+ describe ( 'IPv4 private/reserved ranges' , ( ) => {
570+ it . concurrent . each ( [
571+ [ '192.168.1.1' ] ,
572+ [ '192.168.0.0' ] ,
573+ [ '10.0.0.1' ] ,
574+ [ '10.255.255.255' ] ,
575+ [ '172.16.0.1' ] ,
576+ [ '172.31.255.255' ] ,
577+ [ '127.0.0.1' ] ,
578+ [ '127.255.255.255' ] ,
579+ [ '169.254.169.254' ] ,
580+ [ '0.0.0.0' ] ,
581+ [ '224.0.0.1' ] ,
582+ ] ) ( 'blocks IPv4 %s' , ( ip ) => {
583+ expect ( isPrivateOrReservedIP ( ip ) ) . toBe ( true )
584+ } )
585+ } )
586+
587+ describe ( 'IPv6 reserved ranges' , ( ) => {
588+ it . concurrent . each ( [
589+ [ '::1' ] ,
590+ [ '::' ] ,
591+ [ 'fe80::1' ] ,
592+ [ 'fc00::1' ] ,
593+ [ 'fd00::1' ] ,
594+ [ 'ff02::1' ] ,
595+ [ '2001:db8::1' ] ,
596+ ] ) ( 'blocks IPv6 %s' , ( ip ) => {
597+ expect ( isPrivateOrReservedIP ( ip ) ) . toBe ( true )
598+ } )
599+ } )
600+
601+ describe ( 'IPv4-mapped IPv6 (::ffff:0:0/96)' , ( ) => {
602+ it . concurrent . each ( [
603+ [ '::ffff:192.168.1.1' ] ,
604+ [ '::ffff:127.0.0.1' ] ,
605+ [ '::ffff:169.254.169.254' ] ,
606+ [ '::ffff:c0a8:101' ] ,
607+ [ '::ffff:0:0' ] ,
608+ ] ) ( 'blocks mapped private/reserved %s' , ( ip ) => {
609+ expect ( isPrivateOrReservedIP ( ip ) ) . toBe ( true )
610+ } )
611+
612+ it . concurrent ( 'allows mapped public IPv4 ::ffff:8.8.8.8' , ( ) => {
613+ expect ( isPrivateOrReservedIP ( '::ffff:8.8.8.8' ) ) . toBe ( false )
614+ } )
615+ } )
616+
617+ describe ( 'NAT64 (RFC 6052, 64:ff9b::/96)' , ( ) => {
618+ it . concurrent ( 'blocks NAT64-encoded private IPv4' , ( ) => {
619+ expect ( isPrivateOrReservedIP ( '64:ff9b::192.168.1.1' ) ) . toBe ( true )
620+ } )
621+ } )
622+
623+ describe ( 'IPv4-compatible IPv6 (::a.b.c.d, RFC 4291 §2.5.5.1, deprecated)' , ( ) => {
624+ it . concurrent . each ( [
625+ [ '::c0a8:101' , '192.168.1.1 (URL-normalized hex form)' ] ,
626+ [ '::c0a8:0101' , '192.168.1.1 (zero-padded hex form)' ] ,
627+ [ '::a9fe:a9fe' , '169.254.169.254 (cloud metadata)' ] ,
628+ [ '::7f00:1' , '127.0.0.1 (loopback)' ] ,
629+ [ '::7f00:0001' , '127.0.0.1 (zero-padded)' ] ,
630+ [ '::a00:1' , '10.0.0.1 (RFC1918)' ] ,
631+ [ '::ac10:1' , '172.16.0.1 (RFC1918)' ] ,
632+ [ '::e000:1' , '224.0.0.1 (multicast)' ] ,
633+ [ '::192.168.1.1' , 'dotted form ::192.168.1.1' ] ,
634+ [ '::169.254.169.254' , 'dotted form ::169.254.169.254' ] ,
635+ [ '::127.0.0.1' , 'dotted form ::127.0.0.1' ] ,
636+ [ '::10.0.0.1' , 'dotted form ::10.0.0.1' ] ,
637+ ] ) ( 'blocks %s — %s' , ( ip ) => {
638+ expect ( isPrivateOrReservedIP ( ip ) ) . toBe ( true )
639+ } )
640+
641+ it . concurrent . each ( [
642+ [ '::8.8.8.8' , 'dotted form embedding public IPv4' ] ,
643+ [ '::808:808' , 'hex form embedding 8.8.8.8' ] ,
644+ [ '::0808:0808' , 'zero-padded hex form embedding 8.8.8.8' ] ,
645+ ] ) ( 'allows IPv4-compatible IPv6 with embedded public IPv4 %s — %s' , ( ip ) => {
646+ expect ( isPrivateOrReservedIP ( ip ) ) . toBe ( false )
647+ } )
648+
649+ it . concurrent . each ( [
650+ [ '::ffff:1' , 'embedded 255.255.0.1 (Class E reserved) via parts[6]=0xffff' ] ,
651+ [ '::ffff:0' , 'embedded 255.255.0.0 (Class E reserved)' ] ,
652+ [ '::ffff:abcd' , 'embedded 255.255.171.205 (Class E reserved)' ] ,
653+ [ '::f000:1' , 'embedded 240.0.0.1 (Class E reserved)' ] ,
654+ ] ) ( 'blocks IPv4-compatible IPv6 with Class E embedded IPv4 %s — %s' , ( ip ) => {
655+ expect ( isPrivateOrReservedIP ( ip ) ) . toBe ( true )
656+ } )
657+ } )
658+
659+ describe ( 'non-IPv4-compat unicast IPv6 (must not over-block)' , ( ) => {
660+ it . concurrent . each ( [
661+ [ '2606:4700:4700::1111' ] ,
662+ [ '2001:4860:4860::8888' ] ,
663+ [ '::1:c0a8:101' ] ,
664+ [ '1::c0a8:101' ] ,
665+ [ '1:2:3:4:5:6:c0a8:101' ] ,
666+ ] ) ( 'allows %s' , ( ip ) => {
667+ expect ( isPrivateOrReservedIP ( ip ) ) . toBe ( false )
668+ } )
669+ } )
670+
671+ describe ( 'IPv4 public addresses' , ( ) => {
672+ it . concurrent . each ( [ [ '8.8.8.8' ] , [ '1.1.1.1' ] , [ '1.0.0.1' ] ] ) ( 'allows %s' , ( ip ) => {
673+ expect ( isPrivateOrReservedIP ( ip ) ) . toBe ( false )
674+ } )
675+ } )
676+
677+ describe ( 'IPv4 alternate notations' , ( ) => {
678+ it . concurrent . each ( [ [ '0177.0.0.1' ] , [ '0x7f000001' ] ] ) ( 'blocks loopback notation %s' , ( ip ) => {
679+ expect ( isPrivateOrReservedIP ( ip ) ) . toBe ( true )
680+ } )
681+ } )
682+
683+ describe ( 'invalid input' , ( ) => {
684+ it . concurrent . each ( [ [ 'not-an-ip' ] , [ '' ] , [ '256.256.256.256' ] , [ '::g' ] ] ) ( 'rejects %s' , ( ip ) => {
685+ expect ( isPrivateOrReservedIP ( ip ) ) . toBe ( true )
686+ } )
687+ } )
688+ } )
689+
690+ describe ( 'URL hostname normalization (Node URL parser + isPrivateOrReservedIP integration)' , ( ) => {
691+ it . concurrent ( 'Node normalizes [::192.168.1.1] to [::c0a8:101] and validator blocks it' , ( ) => {
692+ const url = new URL ( 'http://[::192.168.1.1]/' )
693+ const cleanHostname =
694+ url . hostname . startsWith ( '[' ) && url . hostname . endsWith ( ']' )
695+ ? url . hostname . slice ( 1 , - 1 )
696+ : url . hostname
697+ expect ( cleanHostname ) . toBe ( '::c0a8:101' )
698+ expect ( isPrivateOrReservedIP ( cleanHostname ) ) . toBe ( true )
699+ } )
700+
701+ it . concurrent ( 'Node normalizes [::169.254.169.254] and validator blocks the metadata IP' , ( ) => {
702+ const url = new URL ( 'http://[::169.254.169.254]/' )
703+ const cleanHostname = url . hostname . slice ( 1 , - 1 )
704+ expect ( cleanHostname ) . toBe ( '::a9fe:a9fe' )
705+ expect ( isPrivateOrReservedIP ( cleanHostname ) ) . toBe ( true )
706+ } )
707+ } )
708+
565709describe ( 'validateUrlWithDNS' , ( ) => {
566710 describe ( 'basic validation' , ( ) => {
567711 it ( 'should reject invalid URLs' , async ( ) => {
0 commit comments