<?php
/**
 * SSRF guard.
 *
 * Rejects hosts that resolve to private / loopback / link-local / reserved /
 * cloud-metadata addresses before the plugin makes any outbound connection.
 * Shared by destinations (S3-compatible endpoint, FTP, SFTP) and notifiers
 * (webhook URLs) so the same protection is applied everywhere user-supplied
 * hosts are dialed.
 *
 * Policy:
 *   - Dangerous literals (localhost, *.local, *.internal, metadata service) and
 *     any host that resolves to a private/reserved IP are rejected (fail-closed).
 *   - A host that cannot be resolved at all is allowed through — the underlying
 *     connection then fails naturally with a real network error, which is more
 *     useful than a false "blocked" message. (Re-checked again at request time
 *     by callers, so a transiently-unresolvable host can't be used to smuggle a
 *     later-rebinding name past the guard for a long-lived config.)
 *   - Operators backing up to a legitimate LAN target (self-hosted MinIO, a NAS,
 *     an internal webhook) can return true from the `backreload_allow_private_host`
 *     filter to opt a specific host back in.
 *
 * @package Backreload\Core
 */

namespace Backreload\Core;

if ( ! defined( 'ABSPATH' ) ) { exit; }

class Ssrf {

	/**
	 * @param string $host  Hostname or IP literal.
	 * @return string|null   Human-readable reason if unsafe, or null if safe/allowed.
	 */
	public static function unsafe_host_reason( string $host ): ?string {
		$host = trim( $host );
		if ( '' === $host ) {
			return 'No host specified.';
		}

		// Operator override for trusted LAN/internal targets.
		if ( apply_filters( 'backreload_allow_private_host', false, $host ) ) {
			return null;
		}

		// Normalise: strip IPv6 brackets ([::1] -> ::1) and lowercase.
		$lower = strtolower( trim( $host, '[]' ) );

		// Reject dangerous names before resolving them.
		if ( 'localhost' === $lower || 'metadata.google.internal' === $lower ) {
			return 'That host is not allowed (loopback / cloud-metadata service).';
		}
		if ( '.local' === substr( $lower, -6 ) || '.internal' === substr( $lower, -9 ) ) {
			return 'That host is not allowed (internal/mDNS domain).';
		}

		// IP literal? Check it directly — gethostbynamel() only resolves names.
		if ( filter_var( $lower, FILTER_VALIDATE_IP ) ) {
			return self::ip_is_private( $lower )
				? 'That address is private/loopback/reserved (' . $host . ') and is not allowed.'
				: null;
		}

		// Resolve A (IPv4) records, plus AAAA (IPv6) where the platform supports it.
		$ips = @gethostbynamel( $host );
		$ips = is_array( $ips ) ? $ips : [];
		if ( function_exists( 'dns_get_record' ) && defined( 'DNS_AAAA' ) ) {
			$aaaa = @dns_get_record( $host, DNS_AAAA );
			if ( is_array( $aaaa ) ) {
				foreach ( $aaaa as $rec ) {
					if ( ! empty( $rec['ipv6'] ) ) {
						$ips[] = $rec['ipv6'];
					}
				}
			}
		}

		if ( empty( $ips ) ) {
			// Couldn't resolve — let the real connection surface the error.
			return null;
		}

		foreach ( $ips as $ip ) {
			if ( self::ip_is_private( $ip ) ) {
				return 'That host resolves to a private/loopback address (' . $ip . ') and is not allowed.';
			}
		}

		return null;
	}

	/**
	 * @param string $url  Full URL whose host should be checked.
	 * @return string|null  Reason if unsafe, or null if safe/allowed.
	 */
	public static function unsafe_url_reason( string $url ): ?string {
		$host = (string) wp_parse_url( $url, PHP_URL_HOST );
		if ( '' === $host ) {
			return 'URL has no host.';
		}
		return self::unsafe_host_reason( $host );
	}

	/** True when $ip falls in a private, loopback, link-local, or reserved range. */
	private static function ip_is_private( string $ip ): bool {
		// filter_var returns the IP when it is a *public* address, false otherwise.
		return false === filter_var(
			$ip,
			FILTER_VALIDATE_IP,
			FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
		);
	}
}
