Passed
Push — master ( a17548...0dc587 )
by Maximilian
07:37 queued 11s
created

RequestValidator   A

Complexity

Total Complexity 28

Size/Duplication

Total Lines 193
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 28
eloc 48
dl 0
loc 193
rs 10
c 0
b 0
f 0

9 Methods

Rating   Name   Duplication   Size   Complexity  
B validateCertContent() 0 15 8
A validateTimestamp() 0 10 4
A fetchCertData() 0 16 3
A validate() 0 8 2
A validateCertUrl() 0 4 2
A validateSignature() 0 23 3
A parseCertData() 0 8 2
A __construct() 0 4 2
A verifyCert() 0 4 2
1
<?php
2
3
namespace MaxBeckers\AmazonAlexa\Validation;
4
5
use GuzzleHttp\Client;
6
use MaxBeckers\AmazonAlexa\Exception\OutdatedCertExceptionException;
7
use MaxBeckers\AmazonAlexa\Exception\RequestInvalidSignatureException;
8
use MaxBeckers\AmazonAlexa\Exception\RequestInvalidTimestampException;
9
use MaxBeckers\AmazonAlexa\Request\Request;
10
11
/**
12
 * This is a validator for amazon echo requests. It validates the timestamp of the request and the request signature.
13
 *
14
 * @author Maximilian Beckers <[email protected]>
15
 */
16
class RequestValidator
17
{
18
    /**
19
     * Default value for timestamp validation. 150 seconds is suggested by amazon.
20
     */
21
    const TIMESTAMP_VALID_TOLERANCE_SECONDS = 150;
22
23
    /**
24
     * @var Client
25
     */
26
    public $client;
27
28
    /**
29
     * @var int
30
     */
31
    protected $timestampTolerance;
32
33
    /**
34
     * @param int         $timestampTolerance
35
     * @param Client|null $client
36
     */
37
    public function __construct($timestampTolerance = self::TIMESTAMP_VALID_TOLERANCE_SECONDS, Client $client = null)
38
    {
39
        $this->timestampTolerance = $timestampTolerance;
40
        $this->client             = $client ?: new Client();
41
    }
42
43
    /**
44
     * Validate request data.
45
     *
46
     * @param Request $request
47
     *
48
     * @throws OutdatedCertExceptionException
49
     * @throws RequestInvalidSignatureException
50
     * @throws RequestInvalidTimestampException
51
     */
52
    public function validate(Request $request)
53
    {
54
        $this->validateTimestamp($request);
55
        try {
56
            $this->validateSignature($request);
57
        } catch (OutdatedCertExceptionException $e) {
58
            // load cert again and validate because temp file was outdatet.
59
            $this->validateSignature($request);
60
        }
61
    }
62
63
    /**
64
     * Validate request timestamp. Request tolerance should be 150 seconds.
65
     * For more details @see https://developer.amazon.com/public/solutions/alexa/alexa-skills-kit/docs/developing-an-alexa-skill-as-a-web-service#timestamp.
66
     *
67
     * @param Request $request
68
     *
69
     * @throws RequestInvalidTimestampException
70
     */
71
    private function validateTimestamp(Request $request)
72
    {
73
        if (null === $request->request || !$request->request->validateTimestamp()) {
74
            return;
75
        }
76
77
        $differenceInSeconds = time() - $request->request->timestamp->getTimestamp();
78
79
        if ($differenceInSeconds > $this->timestampTolerance) {
80
            throw new RequestInvalidTimestampException('Invalid timestamp.');
81
        }
82
    }
83
84
    /**
85
     * Validate request signature. The steps for signature validation are described at developer page.
86
     *
87
     * @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
88
     *
89
     * @param Request $request
90
     *
91
     * @throws OutdatedCertExceptionException
92
     * @throws RequestInvalidSignatureException
93
     */
94
    private function validateSignature(Request $request)
95
    {
96
        if (null === $request->request || !$request->request->validateSignature()) {
97
            return;
98
        }
99
100
        // validate cert url
101
        $this->validateCertUrl($request);
102
103
        // generate local cert path
104
        $localCertPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.md5($request->signatureCertChainUrl).'.pem';
105
106
        // check if pem file is already downloaded to temp or download.
107
        $certData = $this->fetchCertData($request, $localCertPath);
108
109
        // openssl cert validation
110
        $this->verifyCert($request, $certData);
111
112
        // parse cert
113
        $certContent = $this->parseCertData($certData);
114
115
        // validate cert
116
        $this->validateCertContent($certContent, $localCertPath);
117
    }
118
119
    /**
120
     * @param Request $request
121
     *
122
     * @throws RequestInvalidSignatureException
123
     */
124
    private function validateCertUrl(Request $request)
125
    {
126
        if (false === (bool) preg_match("/https:\/\/s3.amazonaws.com(\:443)?\/echo.api\/*/i", $request->signatureCertChainUrl)) {
127
            throw new RequestInvalidSignatureException('Invalid cert url.');
128
        }
129
    }
130
131
    /**
132
     * @param Request $request
133
     * @param string  $localCertPath
134
     *
135
     * @throws RequestInvalidSignatureException
136
     *
137
     * @return string
138
     */
139
    private function fetchCertData(Request $request, string $localCertPath): string
140
    {
141
        if (!file_exists($localCertPath)) {
142
            $response = $this->client->request('GET', $request->signatureCertChainUrl);
143
144
            if ($response->getStatusCode() !== 200) {
145
                throw new RequestInvalidSignatureException('Can\'t fetch cert from URL.');
146
            }
147
148
            $certData = $response->getBody()->getContents();
149
            @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

149
            /** @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...
150
        } else {
151
            $certData = @file_get_contents($localCertPath);
152
        }
153
154
        return $certData;
155
    }
156
157
    /**
158
     * @param Request $request
159
     * @param string  $certData
160
     *
161
     * @throws RequestInvalidSignatureException
162
     */
163
    private function verifyCert(Request $request, string $certData)
164
    {
165
        if (1 !== @openssl_verify($request->amazonRequestBody, base64_decode($request->signature, true), $certData, 'sha1')) {
0 ignored issues
show
Bug introduced by
'sha1' of type string is incompatible with the type integer expected by parameter $signature_alg of openssl_verify(). ( Ignorable by Annotation )

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

165
        if (1 !== @openssl_verify($request->amazonRequestBody, base64_decode($request->signature, true), $certData, /** @scrutinizer ignore-type */ 'sha1')) {
Loading history...
166
            throw new RequestInvalidSignatureException('Cert ssl verification failed.');
167
        }
168
    }
169
170
    /**
171
     * @param string $certData
172
     *
173
     * @throws RequestInvalidSignatureException
174
     *
175
     * @return array
176
     */
177
    private function parseCertData(string $certData): array
178
    {
179
        $certContent = @openssl_x509_parse($certData);
180
        if (empty($certContent)) {
181
            throw new RequestInvalidSignatureException('Parse cert failed.');
182
        }
183
184
        return $certContent;
185
    }
186
187
    /**
188
     * @param array  $cert
189
     * @param string $localCertPath
190
     *
191
     * @throws OutdatedCertExceptionException
192
     * @throws RequestInvalidSignatureException
193
     */
194
    private function validateCertContent(array $cert, string $localCertPath)
195
    {
196
        // validate cert subject
197
        if (false === isset($cert['extensions']['subjectAltName']) ||
198
            false === stristr($cert['extensions']['subjectAltName'], 'echo-api.amazon.com')
199
        ) {
200
            throw new RequestInvalidSignatureException('Cert subject error.');
201
        }
202
203
        // validate cert validTo time
204
        if (false === isset($cert['validTo_time_t']) || time() > $cert['validTo_time_t'] || false === isset($cert['validFrom_time_t']) || time() < $cert['validFrom_time_t']) {
205
            if (file_exists($localCertPath)) {
206
                @unlink($localCertPath);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). 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

206
                /** @scrutinizer ignore-unhandled */ @unlink($localCertPath);

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...
207
            }
208
            throw new OutdatedCertExceptionException('Cert is outdated.');
209
        }
210
    }
211
}
212