Issues (51)

src/Validation/RequestValidator.php (2 issues)

1
<?php
2
3
declare(strict_types=1);
4
5
namespace MaxBeckers\AmazonAlexa\Validation;
6
7
use GuzzleHttp\Client;
8
use MaxBeckers\AmazonAlexa\Exception\OutdatedCertExceptionException;
9
use MaxBeckers\AmazonAlexa\Exception\RequestInvalidSignatureException;
10
use MaxBeckers\AmazonAlexa\Exception\RequestInvalidTimestampException;
11
use MaxBeckers\AmazonAlexa\Request\Request;
12
13
/**
14
 * This is a validator for amazon echo requests. It validates the timestamp of the request and the request signature.
15
 */
16
class RequestValidator
17
{
18
    /**
19
     * Basic value for timestamp validation. 150 seconds is suggested by amazon.
20
     */
21
    public const TIMESTAMP_VALID_TOLERANCE_SECONDS = 150;
22
23
    /**
24
     * @param int $timestampTolerance Timestamp tolerance in seconds
25
     * @param Client $client HTTP client for fetching certificates
26
     */
27 11
    public function __construct(
28
        protected int $timestampTolerance = self::TIMESTAMP_VALID_TOLERANCE_SECONDS,
29
        public Client $client = new Client(),
30
    ) {
31 11
    }
32
33
    /**
34
     * Validate request data.
35
     *
36
     * @throws OutdatedCertExceptionException
37
     * @throws RequestInvalidSignatureException
38
     * @throws RequestInvalidTimestampException
39
     */
40 11
    public function validate(Request $request): void
41
    {
42 11
        $this->validateTimestamp($request);
43
        try {
44 9
            $this->validateSignature($request);
45 5
        } catch (OutdatedCertExceptionException $e) {
46
            // load cert again and validate because temp file was outdated.
47
            $this->validateSignature($request);
48
        }
49
    }
50
51
    /**
52
     * Validate request timestamp. Request tolerance should be 150 seconds.
53
     * For more details @see https://developer.amazon.com/public/solutions/alexa/alexa-skills-kit/docs/developing-an-alexa-skill-as-a-web-service#timestamp.
54
     *
55
     * @throws RequestInvalidTimestampException
56
     */
57 11
    private function validateTimestamp(Request $request): void
58
    {
59 11
        if (null === $request->request || !$request->request->validateTimestamp()) {
60 1
            return;
61
        }
62
63 10
        $differenceInSeconds = time() - $request->request->timestamp?->getTimestamp();
64
65 10
        if ($differenceInSeconds > $this->timestampTolerance) {
66 2
            throw new RequestInvalidTimestampException('Invalid timestamp.');
67
        }
68
    }
69
70
    /**
71
     * Validate request signature. The steps for signature validation are described at developer page.
72
     *
73
     * @see https://developer.amazon.com/public/solutions/alexa/alexa-skills-kit/docs/developing-an-alexa-skill-as-a-web-service#checking-the-signature-of-the-request
74
     *
75
     * @throws OutdatedCertExceptionException
76
     * @throws RequestInvalidSignatureException
77
     */
78 9
    private function validateSignature(Request $request): void
79
    {
80 9
        if (null === $request->request || !$request->request->validateSignature()) {
81 4
            return;
82
        }
83
84
        // validate cert url
85 5
        $this->validateCertUrl($request);
86
87
        // generate local cert path
88 4
        $localCertPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . md5($request->signatureCertChainUrl) . '.pem';
89
90
        // check if pem file is already downloaded to temp or download.
91 4
        $certData = $this->fetchCertData($request, $localCertPath);
92
93
        // openssl cert validation
94 4
        $this->verifyCert($request, $certData);
95
96
        // parse cert
97
        $certContent = $this->parseCertData($certData);
98
99
        // validate cert
100
        $this->validateCertContent($certContent, $localCertPath);
101
    }
102
103
    /**
104
     * @throws RequestInvalidSignatureException
105
     */
106 5
    private function validateCertUrl(Request $request): void
107
    {
108 5
        if (false === (bool) preg_match("/https:\/\/s3.amazonaws.com(\:443)?\/echo.api\/*/i", $request->signatureCertChainUrl)) {
109 1
            throw new RequestInvalidSignatureException('Invalid cert url.');
110
        }
111
    }
112
113
    /**
114
     * @throws RequestInvalidSignatureException
115
     */
116 4
    private function fetchCertData(Request $request, string $localCertPath): string
117
    {
118 4
        if (!file_exists($localCertPath)) {
119 2
            $response = $this->client->request('GET', $request->signatureCertChainUrl);
120
121 2
            if ($response->getStatusCode() !== 200) {
122
                throw new RequestInvalidSignatureException('Can\'t fetch cert from URL.');
123
            }
124
125 2
            $certData = $response->getBody()->getContents();
126 2
            @file_put_contents($localCertPath, $certData);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for file_put_contents(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

126
            /** @scrutinizer ignore-unhandled */ @file_put_contents($localCertPath, $certData);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
127
        } else {
128 2
            $certData = @file_get_contents($localCertPath);
129
        }
130
131 4
        return $certData;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $certData could return the type false which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
132
    }
133
134
    /**
135
     * @throws RequestInvalidSignatureException
136
     */
137 4
    private function verifyCert(Request $request, string $certData): void
138
    {
139 4
        if (1 !== @openssl_verify($request->amazonRequestBody, base64_decode($request->signature, true), $certData, 'sha1')) {
140 4
            throw new RequestInvalidSignatureException('Cert ssl verification failed.');
141
        }
142
    }
143
144
    /**
145
     * @throws RequestInvalidSignatureException
146
     */
147
    private function parseCertData(string $certData): array
148
    {
149
        $certContent = @openssl_x509_parse($certData);
150
        if (empty($certContent)) {
151
            throw new RequestInvalidSignatureException('Parse cert failed.');
152
        }
153
154
        return $certContent;
155
    }
156
157
    /**
158
     * @throws OutdatedCertExceptionException
159
     * @throws RequestInvalidSignatureException
160
     */
161
    private function validateCertContent(array $cert, string $localCertPath): void
162
    {
163
        $this->validateCertSubject($cert);
164
        $this->validateCertValidTime($cert, $localCertPath);
165
    }
166
167
    /**
168
     * @throws RequestInvalidSignatureException
169
     */
170
    private function validateCertSubject(array $cert): void
171
    {
172
        if (false === isset($cert['extensions']['subjectAltName']) ||
173
            false === stristr($cert['extensions']['subjectAltName'], 'echo-api.amazon.com')
174
        ) {
175
            throw new RequestInvalidSignatureException('Cert subject error.');
176
        }
177
    }
178
179
    /**
180
     * @throws OutdatedCertExceptionException
181
     */
182
    private function validateCertValidTime(array $cert, string $localCertPath): void
183
    {
184
        if (false === isset($cert['validTo_time_t']) || time() > $cert['validTo_time_t'] || false === isset($cert['validFrom_time_t']) || time() < $cert['validFrom_time_t']) {
185
            if (file_exists($localCertPath)) {
186
                /* @scrutinizer ignore-unhandled */ @unlink($localCertPath);
187
            }
188
            throw new OutdatedCertExceptionException('Cert is outdated.');
189
        }
190
    }
191
}
192