@@ -9,8 +9,11 @@ package danger
99
1010import (
1111 "fmt"
12+ "net"
13+ "net/url"
1214 "os"
1315 "path/filepath"
16+ "strconv"
1417 "strings"
1518)
1619
@@ -84,20 +87,115 @@ func ClassifyPath(path string) RiskClass {
8487
8588// ClassifyURL returns a RiskClass for a browser URL.
8689// Internal IPs → system_write; external → network_egress.
90+ // Uses proper IP parsing (handles decimal, octal, hex, IPv6 compressed,
91+ // short forms like 127.1, and all other representations that browsers
92+ // accept via inet_aton-style parsing) instead of string prefix matching
93+ // which was trivially bypassable.
8794func ClassifyURL (rawURL string ) RiskClass {
88- for _ , prefix := range []string {
89- "http://127.0.0.1" , "http://localhost" , "http://10." ,
90- "http://172." , "http://192.168." , "http://[::1]" ,
91- "https://127.0.0.1" , "https://localhost" ,
92- "https://10." , "https://172." , "https://192.168." ,
93- } {
94- if strings .HasPrefix (rawURL , prefix ) {
95+ u , err := url .Parse (rawURL )
96+ if err != nil || u .Scheme == "" || u .Host == "" {
97+ return NetworkEgress // can't parse — don't block, but will fail at fetch time
98+ }
99+
100+ host := u .Hostname ()
101+
102+ // Try as an IP address — uses browser-compatible parsing that handles
103+ // decimal (127.0.0.1), octal (0177.0.0.1), hex (0x7f000001),
104+ // mixed (127.0x1), short (127.1), single-integer (2130706433),
105+ // IPv6 compressed ([::1]), IPv4-mapped IPv6, etc.
106+ if ip := parseBrowserIP (host ); ip != nil {
107+ if ip .IsLoopback () || ip .IsPrivate () || ip .IsLinkLocalUnicast () || ip .IsLinkLocalMulticast () {
108+ return SystemWrite
109+ }
110+ if ip .IsUnspecified () {
95111 return SystemWrite
96112 }
113+ return NetworkEgress
114+ }
115+
116+ // Hostname-based: check well-known private hostnames
117+ hostLower := strings .ToLower (host )
118+ switch hostLower {
119+ case "localhost" , "localhost.localdomain" , "localhost6" , "localhost6.localdomain6" ,
120+ "ip6-localhost" , "ip6-loopback" :
121+ return SystemWrite
122+ }
123+
124+ // *.local (mDNS) resolves to link-local
125+ if strings .HasSuffix (hostLower , ".local" ) {
126+ return SystemWrite
127+ }
128+
129+ // Common cloud metadata endpoints (SSRF targets)
130+ if hostLower == "169.254.169.254" || hostLower == "[fd00:ec2::254]" ||
131+ hostLower == "metadata.google.internal" ||
132+ hostLower == "metadata.internal" ||
133+ strings .HasSuffix (hostLower , ".internal" ) {
134+ return SystemWrite
135+ }
136+
137+ // Docker internal hostnames
138+ if strings .HasSuffix (hostLower , ".docker.internal" ) {
139+ return SystemWrite
97140 }
141+
98142 return NetworkEgress
99143}
100144
145+ // parseBrowserIP parses an IP address using the same rules browsers use
146+ // (inet_aton-style), handling representations that Go's net.ParseIP doesn't:
147+ // - Octal: 0177.0.0.1
148+ // - Hex: 0x7f000001, 0x0.0x0.0x0.0x0
149+ // - Integer: 2130706433
150+ // - Short: 127.1
151+ func parseBrowserIP (host string ) net.IP {
152+ // Try standard parse first (handles IPv6, dotted decimal, etc.)
153+ if ip := net .ParseIP (host ); ip != nil {
154+ return ip
155+ }
156+
157+ // Try inet_aton-style parsing for IPv4 with non-standard representations
158+ parts := strings .Split (host , "." )
159+ if len (parts ) < 1 || len (parts ) > 4 {
160+ return nil
161+ }
162+
163+ var nums []uint32
164+ for _ , p := range parts {
165+ var val uint64
166+ var err error
167+ switch {
168+ case strings .HasPrefix (p , "0x" ) || strings .HasPrefix (p , "0X" ):
169+ val , err = strconv .ParseUint (p [2 :], 16 , 32 )
170+ case strings .HasPrefix (p , "0" ) && len (p ) > 1 :
171+ // Only octal if it starts with 0 and has more digits
172+ // Single "0" is just decimal zero
173+ val , err = strconv .ParseUint (p [1 :], 8 , 32 )
174+ default :
175+ val , err = strconv .ParseUint (p , 10 , 32 )
176+ }
177+ if err != nil || val > 0xFFFFFFFF {
178+ return nil
179+ }
180+ nums = append (nums , uint32 (val ))
181+ }
182+
183+ switch len (nums ) {
184+ case 1 :
185+ // Single number: full 32-bit address
186+ return net .IPv4 (byte (nums [0 ]>> 24 ), byte (nums [0 ]>> 16 ), byte (nums [0 ]>> 8 ), byte (nums [0 ]))
187+ case 2 :
188+ // a.b: a = high byte, b = remaining 24 bits
189+ return net .IPv4 (byte (nums [0 ]), byte (nums [1 ]>> 16 ), byte (nums [1 ]>> 8 ), byte (nums [1 ]))
190+ case 3 :
191+ // a.b.c: a, b = high bytes, c = remaining 16 bits
192+ return net .IPv4 (byte (nums [0 ]), byte (nums [1 ]), byte (nums [2 ]>> 8 ), byte (nums [2 ]))
193+ case 4 :
194+ return net .IPv4 (byte (nums [0 ]), byte (nums [1 ]), byte (nums [2 ]), byte (nums [3 ]))
195+ }
196+ return nil
197+ }
198+
101199// ── Config ─────────────────────────────────────────────────────────────
102200
103201// DangerousConfig defines how dangerous operations are handled.
0 commit comments