Skip to content

Commit 8c446c1

Browse files
authored
Validate the Canonical field (#44)
> If this field appears within a "security.txt" file and the URI used to retrieve that file is not listed within any canonical fields, then the contents of the file SHOULD NOT be trusted. https://www.rfc-editor.org/rfc/rfc9116#name-canonical Close #40
2 parents b605f62 + b30deb8 commit 8c446c1

14 files changed

+318
-42
lines changed

src/Fetcher/SecurityTxtFetcher.php

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
use Spaze\SecurityTxt\SecurityTxt;
2121
use Spaze\SecurityTxt\Violations\SecurityTxtContentTypeInvalid;
2222
use Spaze\SecurityTxt\Violations\SecurityTxtContentTypeWrongCharset;
23-
use Spaze\SecurityTxt\Violations\SecurityTxtSchemeNotHttps;
2423
use Spaze\SecurityTxt\Violations\SecurityTxtTopLevelDiffers;
2524
use Spaze\SecurityTxt\Violations\SecurityTxtTopLevelPathOnly;
2625
use Spaze\SecurityTxt\Violations\SecurityTxtWellKnownPathOnly;
@@ -219,10 +218,6 @@ private function getResult(SecurityTxtFetcherFetchHostResult $wellKnown, Securit
219218
} elseif ($contentTypeHeader->getLowercaseCharset() !== SecurityTxt::CHARSET) {
220219
$errors[] = new SecurityTxtContentTypeWrongCharset($result->getUrl(), $contentTypeHeader->getContentType(), $contentTypeHeader->getCharset());
221220
}
222-
$scheme = parse_url($result->getUrl(), PHP_URL_SCHEME);
223-
if ($scheme !== 'https') {
224-
$errors[] = new SecurityTxtSchemeNotHttps($result->getUrl());
225-
}
226221
return new SecurityTxtFetchResult(
227222
$result->getUrl(),
228223
$result->getFinalUrl(),

src/Json/SecurityTxtJson.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ public function createSecurityTxtFromJsonValues(array $values): SecurityTxt
9696
{
9797
$securityTxt = new SecurityTxt(SecurityTxtValidationLevel::AllowInvalidValuesSilently);
9898
try {
99+
if (isset($values['fileLocation'])) {
100+
if (!is_string($values['fileLocation'])) {
101+
throw new SecurityTxtCannotParseJsonException('fileLocation is not a string');
102+
}
103+
$securityTxt->setFileLocation($values['fileLocation']);
104+
}
99105
if (isset($values['expires'])) {
100106
if (!is_array($values['expires'])) {
101107
throw new SecurityTxtCannotParseJsonException('expires is not an array');

src/Parser/SecurityTxtParser.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ private function processField(int $lineNumber, string $value, SecurityTxtField $
112112
/**
113113
* @throws SecurityTxtCannotVerifySignatureException
114114
*/
115-
public function parseString(string $contents, ?int $expiresWarningThreshold = null, bool $strictMode = false): SecurityTxtParseStringResult
115+
public function parseString(string $contents, ?string $fileLocation = null, ?int $expiresWarningThreshold = null, bool $strictMode = false): SecurityTxtParseStringResult
116116
{
117117
$this->expiresWarningThreshold = $expiresWarningThreshold;
118118
$this->initFieldProcessors();
@@ -125,6 +125,9 @@ public function parseString(string $contents, ?int $expiresWarningThreshold = nu
125125
SecurityTxtField::cases(),
126126
);
127127
$securityTxt = new SecurityTxt(SecurityTxtValidationLevel::AllowInvalidValues);
128+
if ($fileLocation !== null) {
129+
$securityTxt->setFileLocation($fileLocation);
130+
}
128131
for ($lineNumber = 1; $lineNumber <= count($lines); $lineNumber++) {
129132
$line = trim($lines[$lineNumber - 1]);
130133
if (!str_ends_with($lines[$lineNumber - 1], "\n")) {
@@ -170,7 +173,7 @@ public function parseString(string $contents, ?int $expiresWarningThreshold = nu
170173
*/
171174
public function parseFetchResult(SecurityTxtFetchResult $fetchResult, ?int $expiresWarningThreshold = null, bool $strictMode = false): SecurityTxtParseHostResult
172175
{
173-
$parseResult = $this->parseString($fetchResult->getContents(), $expiresWarningThreshold, $strictMode);
176+
$parseResult = $this->parseString($fetchResult->getContents(), $fetchResult->getFinalUrl(), $expiresWarningThreshold, $strictMode);
174177
return new SecurityTxtParseHostResult(
175178
$parseResult->isValid() && $fetchResult->getErrors() === [] && (!$strictMode || $fetchResult->getWarnings() === []),
176179
$parseResult,

src/SecurityTxt.php

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
use Spaze\SecurityTxt\Violations\SecurityTxtEncryptionNotUri;
2828
use Spaze\SecurityTxt\Violations\SecurityTxtExpired;
2929
use Spaze\SecurityTxt\Violations\SecurityTxtExpiresTooLong;
30+
use Spaze\SecurityTxt\Violations\SecurityTxtFileLocationNotHttps;
31+
use Spaze\SecurityTxt\Violations\SecurityTxtFileLocationNotUri;
3032
use Spaze\SecurityTxt\Violations\SecurityTxtHiringNotHttps;
3133
use Spaze\SecurityTxt\Violations\SecurityTxtHiringNotUri;
3234
use Spaze\SecurityTxt\Violations\SecurityTxtPolicyNotHttps;
@@ -42,6 +44,7 @@ final class SecurityTxt implements JsonSerializable
4244
public const string CHARSET = 'charset=utf-8';
4345
public const string CONTENT_TYPE_HEADER = self::CONTENT_TYPE . '; ' . self::CHARSET;
4446

47+
private ?string $fileLocation = null;
4548
private ?SecurityTxtExpires $expires = null;
4649
private ?SecurityTxtSignatureVerifyResult $signatureVerifyResult = null;
4750
private ?SecurityTxtPreferredLanguages $preferredLanguages = null;
@@ -88,13 +91,32 @@ public function __construct(
8891
}
8992

9093

94+
public function setFileLocation(string $fileLocation): void
95+
{
96+
$this->setValue(
97+
function () use ($fileLocation): void {
98+
$this->fileLocation = $fileLocation;
99+
},
100+
function () use ($fileLocation): void {
101+
$this->checkUri($fileLocation, SecurityTxtFileLocationNotUri::class, SecurityTxtFileLocationNotHttps::class);
102+
},
103+
);
104+
}
105+
106+
107+
public function getFileLocation(): ?string
108+
{
109+
return $this->fileLocation;
110+
}
111+
112+
91113
/**
92114
* @throws SecurityTxtError
93115
* @throws SecurityTxtWarning
94116
*/
95117
public function setExpires(SecurityTxtExpires $expires): void
96118
{
97-
$this->setValue(
119+
$this->setFieldValue(
98120
function () use ($expires): SecurityTxtExpires {
99121
return $this->expires = $expires;
100122
},
@@ -137,7 +159,7 @@ public function getSignatureVerifyResult(): ?SecurityTxtSignatureVerifyResult
137159
*/
138160
public function addCanonical(SecurityTxtCanonical $canonical): void
139161
{
140-
$this->setValue(
162+
$this->setFieldValue(
141163
function () use ($canonical): SecurityTxtCanonical {
142164
return $this->canonical[] = $canonical;
143165
},
@@ -162,7 +184,7 @@ public function getCanonical(): array
162184
*/
163185
public function addContact(SecurityTxtContact $contact): void
164186
{
165-
$this->setValue(
187+
$this->setFieldValue(
166188
function () use ($contact): SecurityTxtContact {
167189
return $this->contact[] = $contact;
168190
},
@@ -187,7 +209,7 @@ public function getContact(): array
187209
*/
188210
public function setPreferredLanguages(SecurityTxtPreferredLanguages $preferredLanguages): void
189211
{
190-
$this->setValue(
212+
$this->setFieldValue(
191213
function () use ($preferredLanguages): SecurityTxtPreferredLanguages {
192214
return $this->preferredLanguages = $preferredLanguages;
193215
},
@@ -231,7 +253,7 @@ public function getPreferredLanguages(): ?SecurityTxtPreferredLanguages
231253
*/
232254
public function addAcknowledgments(SecurityTxtAcknowledgments $acknowledgments): void
233255
{
234-
$this->setValue(
256+
$this->setFieldValue(
235257
function () use ($acknowledgments): SecurityTxtAcknowledgments {
236258
return $this->acknowledgments[] = $acknowledgments;
237259
},
@@ -256,7 +278,7 @@ public function getAcknowledgments(): array
256278
*/
257279
public function addHiring(SecurityTxtHiring $hiring): void
258280
{
259-
$this->setValue(
281+
$this->setFieldValue(
260282
function () use ($hiring): SecurityTxtHiring {
261283
return $this->hiring[] = $hiring;
262284
},
@@ -281,7 +303,7 @@ public function getHiring(): array
281303
*/
282304
public function addPolicy(SecurityTxtPolicy $policy): void
283305
{
284-
$this->setValue(
306+
$this->setFieldValue(
285307
function () use ($policy): SecurityTxtPolicy {
286308
return $this->policy[] = $policy;
287309
},
@@ -306,7 +328,7 @@ public function getPolicy(): array
306328
*/
307329
public function addEncryption(SecurityTxtEncryption $encryption): void
308330
{
309-
$this->setValue(
331+
$this->setFieldValue(
310332
function () use ($encryption): SecurityTxtEncryption {
311333
return $this->encryption[] = $encryption;
312334
},
@@ -327,33 +349,49 @@ public function getEncryption(): array
327349

328350

329351
/**
330-
* @param callable(): SecurityTxtFieldValue $setValue
352+
* @param callable(): void $setValue
331353
* @param callable(): void $validator
332-
* @param (callable(): void)|null $warnings
333354
* @return void
334355
*/
335-
private function setValue(callable $setValue, callable $validator, ?callable $warnings = null): void
356+
private function setValue(callable $setValue, callable $validator): void
336357
{
337358
if ($this->validationLevel === SecurityTxtValidationLevel::AllowInvalidValuesSilently) {
338-
$this->orderedFields[] = $setValue();
359+
$setValue();
339360
return;
340361
}
341362
if ($this->validationLevel === SecurityTxtValidationLevel::AllowInvalidValues) {
342-
$this->orderedFields[] = $setValue();
363+
$setValue();
343364
$validator();
344365
} else {
345366
$validator();
346-
$this->orderedFields[] = $setValue();
367+
$setValue();
347368
}
369+
}
370+
371+
372+
/**
373+
* @param callable(): SecurityTxtFieldValue $setValue
374+
* @param callable(): void $validator
375+
* @param (callable(): void)|null $warnings
376+
* @return void
377+
*/
378+
private function setFieldValue(callable $setValue, callable $validator, ?callable $warnings = null): void
379+
{
380+
$this->setValue(
381+
function () use ($setValue): void {
382+
$this->orderedFields[] = $setValue();
383+
},
384+
$validator,
385+
);
348386
if ($warnings !== null) {
349387
$warnings();
350388
}
351389
}
352390

353391

354392
/**
355-
* @param class-string<SecurityTxtAcknowledgmentsNotUri|SecurityTxtCanonicalNotUri|SecurityTxtContactNotUri|SecurityTxtEncryptionNotUri|SecurityTxtHiringNotUri|SecurityTxtPolicyNotUri> $notUriError
356-
* @param class-string<SecurityTxtAcknowledgmentsNotHttps|SecurityTxtCanonicalNotHttps|SecurityTxtContactNotHttps|SecurityTxtEncryptionNotHttps|SecurityTxtHiringNotHttps|SecurityTxtPolicyNotHttps> $notHttpsError
393+
* @param class-string<SecurityTxtAcknowledgmentsNotUri|SecurityTxtCanonicalNotUri|SecurityTxtContactNotUri|SecurityTxtEncryptionNotUri|SecurityTxtHiringNotUri|SecurityTxtPolicyNotUri|SecurityTxtFileLocationNotUri> $notUriError
394+
* @param class-string<SecurityTxtAcknowledgmentsNotHttps|SecurityTxtCanonicalNotHttps|SecurityTxtContactNotHttps|SecurityTxtEncryptionNotHttps|SecurityTxtHiringNotHttps|SecurityTxtPolicyNotHttps|SecurityTxtFileLocationNotHttps> $notHttpsError
357395
* @throws SecurityTxtError
358396
*/
359397
private function checkUri(string $uri, string $notUriError, string $notHttpsError): void
@@ -384,6 +422,7 @@ public function getOrderedFields(): array
384422
public function jsonSerialize(): array
385423
{
386424
return [
425+
'fileLocation' => $this->getFileLocation(),
387426
'expires' => $this->getExpires(),
388427
'signatureVerifyResult' => $this->getSignatureVerifyResult(),
389428
'preferredLanguages' => $this->getPreferredLanguages(),

src/Validator/SecurityTxtValidator.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Spaze\SecurityTxt\Exceptions\SecurityTxtError;
77
use Spaze\SecurityTxt\Exceptions\SecurityTxtWarning;
88
use Spaze\SecurityTxt\SecurityTxt;
9+
use Spaze\SecurityTxt\Validator\Validators\CanonicalUriListedFieldValidator;
910
use Spaze\SecurityTxt\Validator\Validators\ContactMissingFieldValidator;
1011
use Spaze\SecurityTxt\Validator\Validators\ExpiresMissingFieldValidator;
1112
use Spaze\SecurityTxt\Validator\Validators\FieldValidator;
@@ -23,6 +24,7 @@ final class SecurityTxtValidator
2324
public function __construct()
2425
{
2526
$this->fieldValidators = [
27+
new CanonicalUriListedFieldValidator(),
2628
new ContactMissingFieldValidator(),
2729
new ExpiresMissingFieldValidator(),
2830
new SignedButCanonicalMissingFieldValidator(),
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
declare(strict_types = 1);
3+
4+
namespace Spaze\SecurityTxt\Validator\Validators;
5+
6+
use Override;
7+
use Spaze\SecurityTxt\Exceptions\SecurityTxtWarning;
8+
use Spaze\SecurityTxt\SecurityTxt;
9+
use Spaze\SecurityTxt\Violations\SecurityTxtCanonicalUriMismatch;
10+
11+
final class CanonicalUriListedFieldValidator implements FieldValidator
12+
{
13+
14+
#[Override]
15+
public function validate(SecurityTxt $securityTxt): void
16+
{
17+
$uri = $securityTxt->getFileLocation();
18+
if ($uri === null) {
19+
return;
20+
}
21+
22+
$canonicals = $securityTxt->getCanonical();
23+
if ($canonicals === []) {
24+
return;
25+
}
26+
27+
$canonicalUris = array_map(fn($canonical) => $canonical->getUri(), $canonicals);
28+
if (!in_array($uri, $canonicalUris, true)) {
29+
throw new SecurityTxtWarning(new SecurityTxtCanonicalUriMismatch($uri, $canonicalUris));
30+
}
31+
}
32+
33+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
declare(strict_types = 1);
3+
4+
namespace Spaze\SecurityTxt\Violations;
5+
6+
use Spaze\SecurityTxt\Fields\SecurityTxtField;
7+
8+
final class SecurityTxtCanonicalUriMismatch extends SecurityTxtSpecViolation
9+
{
10+
11+
/**
12+
* @param list<string> $canonicalUris
13+
*/
14+
public function __construct(string $uri, array $canonicalUris)
15+
{
16+
$count = count($canonicalUris);
17+
if ($count === 1) {
18+
$messageFormat = 'The file was fetched from %s but the %s field (%s) does not list this URI';
19+
$howToFixFormat = 'Add a new %s field with the URI %s, or ensure the file is fetched from the listed canonical URI';
20+
} else {
21+
$fields = implode(', ', array_fill(0, $count, '%s'));
22+
$messageFormat = 'The file was fetched from %s but none of the %s fields (' . $fields . ') list this URI';
23+
$howToFixFormat = 'Add a new %s field with the URI %s, or ensure the file is fetched from one of the listed canonical URIs';
24+
}
25+
parent::__construct(
26+
func_get_args(),
27+
$messageFormat,
28+
[$uri, SecurityTxtField::Canonical->value, ...$canonicalUris],
29+
'draft-foudil-securitytxt-05',
30+
null,
31+
$howToFixFormat,
32+
[SecurityTxtField::Canonical->value, $uri],
33+
'2.5.2',
34+
);
35+
}
36+
37+
}

src/Violations/SecurityTxtSchemeNotHttps.php renamed to src/Violations/SecurityTxtFileLocationNotHttps.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@
33

44
namespace Spaze\SecurityTxt\Violations;
55

6-
final class SecurityTxtSchemeNotHttps extends SecurityTxtSpecViolation
6+
final class SecurityTxtFileLocationNotHttps extends SecurityTxtSpecViolation
77
{
88

9-
public function __construct(string $url)
9+
public function __construct(string $uri)
1010
{
1111
parent::__construct(
1212
func_get_args(),
1313
"The file at %s must use HTTPS",
14-
[$url],
14+
[$uri],
1515
'draft-foudil-securitytxt-06',
16-
preg_replace('~^http://~', 'https://', $url),
16+
preg_replace('~^http://~', 'https://', $uri),
1717
'Use HTTPS to serve the %s file',
1818
['security.txt'],
1919
'3',
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
declare(strict_types = 1);
3+
4+
namespace Spaze\SecurityTxt\Violations;
5+
6+
final class SecurityTxtFileLocationNotUri extends SecurityTxtSpecViolation
7+
{
8+
9+
public function __construct(string $uri)
10+
{
11+
parent::__construct(
12+
func_get_args(),
13+
"The location of the file %s doesn't follow the URI syntax described in RFC 3986, the scheme is missing",
14+
[$uri],
15+
'draft-foudil-securitytxt-00',
16+
null,
17+
'Use a URI as the value',
18+
[],
19+
'3',
20+
);
21+
}
22+
23+
}

tests/Check/SecurityTxtCheckHostResultFactoryTest.phpt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ final class SecurityTxtCheckHostResultFactoryTest extends TestCase
4747
"Expires: 2020-10-15T00:01:02+02:00\n",
4848
];
4949
$contents = implode('', $lines);
50-
$parseStringResult = $this->parser->parseString($contents, 123, true);
50+
$parseStringResult = $this->parser->parseString($contents, null, 123, true);
5151
$fetchResult = new SecurityTxtFetchResult(
5252
'https://com.example/.well-known/security.txt',
5353
'https://com.example/.well-known/security.txt',

0 commit comments

Comments
 (0)