@@ -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,138 @@ 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+ [ '::a:0' , '10.0.0.0 (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+
650+ describe ( 'non-IPv4-compat unicast IPv6 (must not over-block)' , ( ) => {
651+ it . concurrent . each ( [
652+ [ '2606:4700:4700::1111' ] ,
653+ [ '2001:4860:4860::8888' ] ,
654+ [ '::1:c0a8:101' ] ,
655+ [ '1::c0a8:101' ] ,
656+ [ '1:2:3:4:5:6:c0a8:101' ] ,
657+ ] ) ( 'allows %s' , ( ip ) => {
658+ expect ( isPrivateOrReservedIP ( ip ) ) . toBe ( false )
659+ } )
660+ } )
661+
662+ describe ( 'IPv4 public addresses' , ( ) => {
663+ it . concurrent . each ( [ [ '8.8.8.8' ] , [ '1.1.1.1' ] , [ '1.0.0.1' ] ] ) ( 'allows %s' , ( ip ) => {
664+ expect ( isPrivateOrReservedIP ( ip ) ) . toBe ( false )
665+ } )
666+ } )
667+
668+ describe ( 'IPv4 alternate notations' , ( ) => {
669+ it . concurrent . each ( [ [ '0177.0.0.1' ] , [ '0x7f000001' ] ] ) ( 'blocks loopback notation %s' , ( ip ) => {
670+ expect ( isPrivateOrReservedIP ( ip ) ) . toBe ( true )
671+ } )
672+ } )
673+
674+ describe ( 'invalid input' , ( ) => {
675+ it . concurrent . each ( [ [ 'not-an-ip' ] , [ '' ] , [ '256.256.256.256' ] , [ '::g' ] ] ) ( 'rejects %s' , ( ip ) => {
676+ expect ( isPrivateOrReservedIP ( ip ) ) . toBe ( true )
677+ } )
678+ } )
679+ } )
680+
681+ describe ( 'URL hostname normalization (Node URL parser + isPrivateOrReservedIP integration)' , ( ) => {
682+ it . concurrent ( 'Node normalizes [::192.168.1.1] to [::c0a8:101] and validator blocks it' , ( ) => {
683+ const url = new URL ( 'http://[::192.168.1.1]/' )
684+ const cleanHostname =
685+ url . hostname . startsWith ( '[' ) && url . hostname . endsWith ( ']' )
686+ ? url . hostname . slice ( 1 , - 1 )
687+ : url . hostname
688+ expect ( cleanHostname ) . toBe ( '::c0a8:101' )
689+ expect ( isPrivateOrReservedIP ( cleanHostname ) ) . toBe ( true )
690+ } )
691+
692+ it . concurrent ( 'Node normalizes [::169.254.169.254] and validator blocks the metadata IP' , ( ) => {
693+ const url = new URL ( 'http://[::169.254.169.254]/' )
694+ const cleanHostname = url . hostname . slice ( 1 , - 1 )
695+ expect ( cleanHostname ) . toBe ( '::a9fe:a9fe' )
696+ expect ( isPrivateOrReservedIP ( cleanHostname ) ) . toBe ( true )
697+ } )
698+ } )
699+
565700describe ( 'validateUrlWithDNS' , ( ) => {
566701 describe ( 'basic validation' , ( ) => {
567702 it ( 'should reject invalid URLs' , async ( ) => {
0 commit comments