@@ -1963,7 +1963,25 @@ function url_get_contents($url, $ctx = "", $timeout = 0, $debug = false, $mantai
19631963 }
19641964 if (empty ($ ctx )) {
19651965 $ opts = [
1966- 'http ' => ['header ' => "User-Agent: {$ agent }\r\n" ],
1966+ 'http ' => [
1967+ 'header ' => "User-Agent: {$ agent }\r\n" ,
1968+ // SECURITY: Disable PHP's automatic HTTP redirect following.
1969+ //
1970+ // The problem this solves (called "SSRF via open redirect"):
1971+ // Before making a request, we call isSSRFSafeURL() to confirm the
1972+ // destination is not an internal/private server (e.g. 192.168.x.x
1973+ // or 169.254.169.254, the AWS cloud metadata endpoint).
1974+ // BUT if PHP silently follows redirects on its own, an attacker can
1975+ // point us at a public URL like https://attacker.com/redir that then
1976+ // redirects to http://169.254.169.254/secrets -- bypassing our check
1977+ // completely, because we only verified the *first* URL.
1978+ //
1979+ // The fix:
1980+ // Set follow_location = 0 so PHP stops at every redirect and hands
1981+ // control back to us. We then re-check each redirect target ourselves
1982+ // with isSSRFSafeURL() before deciding whether to follow it.
1983+ 'follow_location ' => 0 ,
1984+ ],
19671985 'ssl ' => [
19681986 'verify_peer ' => false ,
19691987 'verify_peer_name ' => false ,
@@ -1986,15 +2004,51 @@ function url_get_contents($url, $ctx = "", $timeout = 0, $debug = false, $mantai
19862004 _error_log ("url_get_contents: allow_url_fopen {$ url }" );
19872005 }
19882006 try {
1989- if ($ debug ) {
1990- $ tmp = file_get_contents ($ url , false , $ context );
1991- } else {
1992- $ tmp = @file_get_contents ($ url , false , $ context );
2007+ $ tmp = false ;
2008+ $ currentUrl = $ url ;
2009+ // SECURITY: Manual redirect loop -- we follow up to 5 redirects ourselves
2010+ // so we can run isSSRFSafeURL() on every new destination URL.
2011+ // Without this loop, PHP would never follow any redirect at all (because
2012+ // we disabled follow_location above), breaking legitimate HTTP->HTTPS
2013+ // upgrades. This gives us both safety and correct behavior.
2014+ for ($ redirectCount = 0 ; $ redirectCount <= 5 ; $ redirectCount ++) {
2015+ $ fetched = $ debug
2016+ ? file_get_contents ($ currentUrl , false , $ context )
2017+ : @file_get_contents ($ currentUrl , false , $ context );
2018+ if ($ fetched === false ) {
2019+ break ;
2020+ }
2021+ // $http_response_header is a PHP magic variable automatically populated
2022+ // by file_get_contents() with the raw HTTP response headers.
2023+ // We check whether the server responded with a 3xx redirect status code.
2024+ $ redirectTarget = null ;
2025+ if (!empty ($ http_response_header ) && preg_match ('/^HTTP\/\S+\s+3\d\d\s/i ' , $ http_response_header [0 ])) {
2026+ foreach ($ http_response_header as $ h ) {
2027+ if (preg_match ('/^Location:\s*(.+)$/i ' , $ h , $ m )) {
2028+ $ redirectTarget = trim ($ m [1 ]);
2029+ break ;
2030+ }
2031+ }
2032+ }
2033+ if ($ redirectTarget ) {
2034+ // SECURITY: Re-validate the redirect destination before following it.
2035+ // This is the core of the fix -- every redirect hop is checked.
2036+ // If the target is an internal/private/reserved IP, we stop
2037+ // immediately and return false instead of leaking data.
2038+ if (!isSSRFSafeURL ($ redirectTarget )) {
2039+ _error_log ("url_get_contents: blocked unsafe redirect from {$ currentUrl } to {$ redirectTarget }" );
2040+ return false ;
2041+ }
2042+ $ currentUrl = $ redirectTarget ;
2043+ continue ;
2044+ }
2045+ // No redirect -- this is the final response body we wanted.
2046+ $ tmp = $ fetched ;
2047+ break ;
19932048 }
19942049 if ($ tmp !== false ) {
19952050 $ response = remove_utf8_bom ($ tmp );
19962051 if ($ debug ) {
1997- //_error_log("url_get_contents: SUCCESS file_get_contents($url) {$response}");
19982052 _error_log ("url_get_contents: SUCCESS file_get_contents( $ url) " );
19992053 }
20002054 return $ response ;
0 commit comments