メールフォームでメールアドレスのドメインをDNS-over-HTTPS (DoH)でチェックするカスタムバリデーションを作成する

# 目的

メールフォームから登録されるメールアドレスが、入力間違いがないかチェックをしたい。
メールアドレスのドメインをDNSに問い合わせを行って、間違ったドメインでないか確認をする。

# Symfonyのバリデーションでデフォルトであるのでは？

Symfonyのバリデーションを見ていると、[Email](https://symfony.com/doc/4.4/reference/constraints/Email.html)でのバリデーションが用意されています。

Ver 4.2で非推奨になりましたが、以前は、Emailバリデーションのオプションで<code>checkHost</code>や<code>checkMX</code>を渡すことで、HostやMXのチェックしてくれたようです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133291/069dae84-524c-88e1-b1c9-59c3551d4513.png)

しかし、ネットワーク状況によって安定しないので非推奨になったということです。


実装を見てみると、PHPの標準関数の<code>[checkdnsrr](https://www.php.net/manual/ja/function.checkdnsrr.php)</code>でシンプルに作られています。

https://github.com/symfony/symfony/blob/0578fdf038f225524929373bf1b0d33129f1be13/src/Symfony/Component/Validator/Constraints/EmailValidator.php#L157

```php:symfony/src/Symfony/Component/Validator/Constraints/EmailValidator.php 
    /**
     * Check DNS Records for MX type.
     */
    private function checkMX(string $host): bool
    {
        return '' !== $host && checkdnsrr($host, 'MX');
    }

    /**
     * Check if one of MX, A or AAAA DNS RR exists.
     */
    private function checkHost(string $host): bool
    {
        return '' !== $host && ($this->checkMX($host) || (checkdnsrr($host, 'A') || checkdnsrr($host, 'AAAA')));
    }
```

# DNS-over-HTTPS (DoH) について

DNSをHTTPSで検索できるそうです。

参考にさせていただいたのは以下の記事。

* [Swooleによる非同期リクエストとGuzzleによる非同期リクエストのベンチマーク](https://qiita.com/gorogoroyasu/items/44119f8b2ba725859018)
* [@uzulla](https://twitter.com/uzulla) さんの[最近メールフォームかいてないメールフォーマーによる、ちょっとだけ変わった一応PHPのメールフォーム(?)のお話](https://speakerdeck.com/uzulla/zui-jin-meruhuomukaitenaimeruhuomaniyoru-tiyotutodakebian-watuta-ying-phpfalsemeruhuomu-falseohua-fu-ti-zui-jin-swoolegatafalsesiidesu)
* [DNS-over-HTTPS (DoH)](https://developers.google.com/speed/public-dns/docs/doh)
* [JSON API for DNS over HTTPS (DoH)](https://developers.google.com/speed/public-dns/docs/doh/json)

@usullaさんのスライドに実装まで書いてあるので参考にさせてもらいました。


# curlで試してみる。

[GoogleのJsonでのサンプル](https://developers.google.com/speed/public-dns/docs/secure-transports#doh)がありますので、レスポンス情報など実感を掴むため操作してみます。

```terminal

% curl -i 'https://dns.google/resolve?name=example.com&type=a&do=1'
HTTP/2 200
strict-transport-security: max-age=31536000; includeSubDomains; preload
access-control-allow-origin: *
date: Fri, 31 Jan 2020 09:18:02 GMT
expires: Fri, 31 Jan 2020 09:18:02 GMT
cache-control: private, max-age=7189
content-type: application/x-javascript; charset=UTF-8
server: HTTP server (unknown)
x-xss-protection: 0
x-frame-options: SAMEORIGIN
alt-svc: quic=":443"; ma=2592000; v="46,43",h3-Q050=":443"; ma=2592000,h3-Q049=":443"; ma=2592000,h3-Q048=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000
accept-ranges: none
vary: Accept-Encoding

{"Status": 0,"TC": false,"RD": true,"RA": true,"AD": true,"CD": false,"Question":[ {"name": "example.com.","type": 1}],"Answer":[ {"name": "example.com.","type": 1,"TTL": 7189,"data": "93.184.216.34"},{"name": "example.com.","type": 46,"TTL": 7189,"data": "a 8 2 86400 1582004640 1580178069 5418 example.com. Y0/Mb+trXbRNs2YCCVBhwkGNQmYmcmgxahO4aAe70OMX4Z/AyWjHLZuxFyEIZSpWhfbiJXaNFn3ZCt6nUoHhbATrsNsIMhass8y3lJB1/Ka4MvsK2iISctdVZ8oQ77S+fTB5lZXZEV5K94hazNavmt3oBb4MIX7sKS1iE/MPRxs="}],"Additional":[]}%
```

レスポンスが見づらいので整形します。

```json
{
    "Status": 0,
    "TC": false,
    "RD": true,
    "RA": true,
    "AD": true,
    "CD": false,
    "Question": [
        {
            "name": "example.com.",
            "type": 1
        }
    ],
    "Answer": [
        {
            "name": "example.com.",
            "type": 1,
            "TTL": 7189,
            "data": "93.184.216.34"
        },
        {
            "name": "example.com.",
            "type": 46,
            "TTL": 7189,
            "data": "a 8 2 86400 1582004640 1580178069 5418 example.com. Y0/Mb+trXbRNs2YCCVBhwkGNQmYmcmgxahO4aAe70OMX4Z/AyWjHLZuxFyEIZSpWhfbiJXaNFn3ZCt6nUoHhbATrsNsIMhass8y3lJB1/Ka4MvsK2iISctdVZ8oQ77S+fTB5lZXZEV5K94hazNavmt3oBb4MIX7sKS1iE/MPRxs="
        }
    ],
    "Additional": []
}
```
[JSON API for DNS over HTTPS (DoH)](https://developers.google.com/speed/public-dns/docs/doh/json)のドキュメントをみると、Typeで問い合わせを変更できるようです。

TypeにMXを指定してみます。ドメインもexampleだとつまらないので、自分のドメインにしてみます。

```terminal
% curl -i 'https://dns.google/resolve?name=hirotae.com&type=mx&do=1'
HTTP/2 200
strict-transport-security: max-age=31536000; includeSubDomains; preload
access-control-allow-origin: *
date: Fri, 31 Jan 2020 09:22:43 GMT
expires: Fri, 31 Jan 2020 09:22:43 GMT
cache-control: private, max-age=1199
content-type: application/x-javascript; charset=UTF-8
server: HTTP server (unknown)
x-xss-protection: 0
x-frame-options: SAMEORIGIN
alt-svc: quic=":443"; ma=2592000; v="46,43",h3-Q050=":443"; ma=2592000,h3-Q049=":443"; ma=2592000,h3-Q048=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000
accept-ranges: none
vary: Accept-Encoding

{"Status": 0,"TC": false,"RD": true,"RA": true,"AD": false,"CD": false,"Question":[ {"name": "hirotae.com.","type": 15}],"Answer":[ {"name": "hirotae.com.","type": 15,"TTL": 1199,"data": "10 hirotae.com."}],"Additional":[],"Comment": "Response from 13.113.251.9."}
```

きちんとAnswerに回答が入っています。

```json
{
    "Status": 0,
    "TC": false,
    "RD": true,
    "RA": true,
    "AD": false,
    "CD": false,
    "Question": [
        {
            "name": "hirotae.com.",
            "type": 15
        }
    ],
    "Answer": [
        {
            "name": "hirotae.com.",
            "type": 15,
            "TTL": 1199,
            "data": "10 hirotae.com."
        }
    ],
    "Additional": [],
    "Comment": "Response from 13.113.251.9."
}
```

typeに255を設定すると全て問い合わせをしてくれるようなので試してみます。

```terminal
% curl -i 'https://dns.google/resolve?name=hirotae.com&type=255&do=1'
HTTP/2 200
strict-transport-security: max-age=31536000; includeSubDomains; preload
access-control-allow-origin: *
date: Fri, 31 Jan 2020 09:24:55 GMT
expires: Fri, 31 Jan 2020 09:24:55 GMT
cache-control: private, max-age=1199
content-type: application/x-javascript; charset=UTF-8
server: HTTP server (unknown)
x-xss-protection: 0
x-frame-options: SAMEORIGIN
alt-svc: quic=":443"; ma=2592000; v="46,43",h3-Q050=":443"; ma=2592000,h3-Q049=":443"; ma=2592000,h3-Q048=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000
accept-ranges: none
vary: Accept-Encoding

{"Status": 0,"TC": false,"RD": true,"RA": true,"AD": false,"CD": false,"Question":[ {"name": "hirotae.com.","type": 255}],"Answer":[ {"name": "hirotae.com.","type": 1,"TTL": 1199,"data": "150.95.8.211"},{"name": "hirotae.com.","type": 2,"TTL": 1199,"data": "ns11.value-domain.com."},{"name": "hirotae.com.","type": 2,"TTL": 1199,"data": "ns12.value-domain.com."},{"name": "hirotae.com.","type": 2,"TTL": 1199,"data": "ns13.value-domain.com."},{"name": "hirotae.com.","type": 6,"TTL": 2559,"data": "ns13.value-domain.com. hostmaster.hirotae.com. 1580462514 16384 2048 1048576 2560"},{"name": "hirotae.com.","type": 15,"TTL": 1199,"data": "10 hirotae.com."},{"name": "hirotae.com.","type": 16,"TTL": 1199,"data": "\"v=spf1 ip4:150.95.8.211 +ipv4:59.106.27.210 ~all\""}],"Additional":[],"Comment": "Response from 54.199.222.7."}%
```

こんな感じでAnswerにズラズラと返ってきます。

```json

{
    "Status": 0,
    "TC": false,
    "RD": true,
    "RA": true,
    "AD": false,
    "CD": false,
    "Question": [
        {
            "name": "hirotae.com.",
            "type": 255
        }
    ],
    "Answer": [
        {
            "name": "hirotae.com.",
            "type": 1,
            "TTL": 1199,
            "data": "150.95.8.211"
        },
        {
            "name": "hirotae.com.",
            "type": 2,
            "TTL": 1199,
            "data": "ns11.value-domain.com."
        },
        {
            "name": "hirotae.com.",
            "type": 2,
            "TTL": 1199,
            "data": "ns12.value-domain.com."
        },
        {
            "name": "hirotae.com.",
            "type": 2,
            "TTL": 1199,
            "data": "ns13.value-domain.com."
        },
        {
            "name": "hirotae.com.",
            "type": 6,
            "TTL": 2559,
            "data": "ns13.value-domain.com. hostmaster.hirotae.com. 1580462514 16384 2048 1048576 2560"
        },
        {
            "name": "hirotae.com.",
            "type": 15,
            "TTL": 1199,
            "data": "10 hirotae.com."
        },
        {
            "name": "hirotae.com.",
            "type": 16,
            "TTL": 1199,
            "data": "\"v=spf1 ip4:150.95.8.211 +ipv4:59.106.27.210 ~all\""
        }
    ],
    "Additional": [],
    "Comment": "Response from 54.199.222.7."
}
```

AnswerのTypeを見ていくと、以下のような感じのようです。

* 1: A
* 2: ns
* 6: soa
* 15: mx
* 16: txt



# Symfonyのバリデーションとして実装してみる。

https://github.com/idani/EmailDoHValidator
で公開しています。

以下を参考にしてカスタムバリデーションを作成していきます。

* [How to Create a custom Validation Constraint](https://symfony.com/doc/4.4/validation/custom_constraint.html)
* [SymfonyのEmailバリデーション](https://github.com/symfony/symfony/blob/0578fdf038f225524929373bf1b0d33129f1be13/src/Symfony/Component/Validator/Constraints/EmailValidator.php#L157)

```php:/src/Validator/Constraints/EmailByDoH.php
<?php

namespace App\Validator\Constraints;

use Symfony\Component\Validator\Constraint;

class EmailByDoH extends Constraint
{
    public $message = 'This value is not a valid email address.';
    public $checkMX = true;
    public $checkHost = true;
}
```

AレコードとMXで2回もHTTPリクエストはしたくないので、1回にまとめて、結果を再利用できるようにしてみました。間違ってたりして。

```php

<?php


namespace App\Validator\Constraints;


use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;

class EmailByDoHValidator extends ConstraintValidator
{

    const DNS_A = 1;
    const DNS_NS = 3;
    const DNS_SOA = 6;
    const DNS_MX = 15;
    const DNS_TXT = 16;

    /**
     * @inheritDoc
     */
    public function validate($value, Constraint $constraint)
    {
        if (!$constraint instanceof EmailByDoH) {
            throw new UnexpectedTypeException($constraint, EmailByDoH::class);
        }

        // custom constraints should ignore null and empty values to allow
        // other constraints (NotBlank, NotNull, etc.) take care of that
        if (null === $value || '' === $value) {
            return;
        }

        if (!is_scalar($value) && !(\is_object($value) && method_exists($value, '__toString'))) {
            throw new UnexpectedValueException($value, 'string');
        }

        $value = (string)$value;
        $host = (string)substr($value, strrpos($value, '@') + 1);

        // Check for host DNS resource records
        if ($constraint->checkMX) {
            if (!$this->checkMXByDoH($host)) {
                $this->context->buildViolation($constraint->message)
                    ->setParameter('{{ value }}', $this->formatValue($value))
                    ->addViolation();
            }

            return;
        }

        if ($constraint->checkHost && !$this->checkHostByDoH($host)) {
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ value }}', $this->formatValue($value))
                ->addViolation();
        }

    }


    /**
     * @param string $host
     * @return bool
     */
    public function checkMXByDoH(string $host): bool
    {
        return $this->checkDoH($host, self::DNS_MX);
    }

    /**
     * @param string $host
     * @return bool
     */
    public function checkHostByDoH(string $host): bool
    {
        return $this->checkDoH($host, self::DNS_A);
    }

    /**
     * @param string $host
     * @param int $type
     * @return bool
     */
    public function checkDoH(string $host, int $type): bool
    {
        static $result = [];

        if (empty(trim($host))) {
            return false;
        }

        if (!in_array($type, [
            self::DNS_A,
            self::DNS_MX,
            self::DNS_NS,
            self::DNS_SOA,
            self::DNS_TXT
        ], true)) {
            return false;
        }

        if (isset($result[$host])) {
            return isset($result[$host][$type]);
        }

        try {
            $client = HttpClient::create();
            $response = $client->request('GET', 'https://dns.google/resolve', [
                'query' => [
                    'name' => urlencode($host),
                    'type' => 255,
                    'do' => true
                ]
            ]);

            if ($response->getStatusCode() !== 200) {
                return false;
            }

            $contents = $response->getContent();
        } catch (ExceptionInterface $e) {
            return false;
        }

        $dnsRecode = json_decode($contents, true);

        if ($dnsRecode['Status'] != 0) {
            return false;
        }

        if (!isset($dnsRecode['Answer']) || !is_array($dnsRecode['Answer'])) {
            return false;
        }

        $hAnswers = $dnsRecode['Answer'];
        foreach ($hAnswers as $hAnswer) {
            $qtype = $hAnswer['type'];
            $result[$host][$qtype] = $hAnswer;
        }

        return isset($result[$host][$type]);
    }
}
```

# サンプル

以下のようにチェックできるようになりました。

![2020-01-31_18h54_57.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133291/c66b7316-319f-3742-7a38-d779cab5c0e4.gif)