@tryghost/domain-validator 1.6.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2013-2025 Ghost Foundation
3
+ Copyright (c) 2013-2026 Ghost Foundation
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -18,4 +18,4 @@
18
18
 
19
19
  # Copyright & License
20
20
 
21
- Copyright (c) 2013-2025 Ghost Foundation - Released under the [MIT license](LICENSE).
21
+ Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE).
package/lib/caa.js ADDED
@@ -0,0 +1,37 @@
1
+ const dns = require('@tryghost/dns');
2
+ const debug = require('@tryghost/debug')('dv:caa');
3
+
4
+ /**
5
+ * Check if the domain's CAA records allow the given issuer.
6
+ *
7
+ * @param {string} customdomain
8
+ * @param {string} issuer - CA identity to check (e.g. 'certainly.com')
9
+ * @returns {Promise<{isCaaValid: boolean, caaSource: string|null, caaAllowedIssuers: string[], caaError: string|null}>}
10
+ */
11
+ module.exports.validate = (customdomain, issuer) => {
12
+ debug('Checking CAA for ' + customdomain + ' against ' + issuer);
13
+ return dns.caa(customdomain, {issuer}).then((result) => {
14
+ return {
15
+ isCaaValid: result.allowed,
16
+ caaSource: result.source,
17
+ caaAllowedIssuers: result.allowedIssuers,
18
+ caaError: result.error
19
+ };
20
+ }).catch((err) => {
21
+ // Fail open: dns.caa fails open internally on DNS errors, but guard
22
+ // against unexpected throws (bad arguments, future regressions) so
23
+ // CAA can never take down the rest of the validity report. Tolerate
24
+ // non-Error rejections (null/undefined/strings) so this handler
25
+ // itself can never throw.
26
+ const message = (err && typeof err.message === 'string')
27
+ ? err.message
28
+ : String(err || 'UNKNOWN');
29
+ debug('Unexpected CAA error: ' + message);
30
+ return {
31
+ isCaaValid: true,
32
+ caaSource: null,
33
+ caaAllowedIssuers: [],
34
+ caaError: message
35
+ };
36
+ });
37
+ };
package/lib/pro.js CHANGED
@@ -150,19 +150,19 @@ _private.isCertificateError = error => /certificate/.test(error.context);
150
150
 
151
151
  // Checks a Pro site using the provided details, and returning a responseObject
152
152
  module.exports.validate = (proto, subdomain, customdomain, subdirectory, prodomain) => {
153
- const url = _private.buildUrl(proto, customdomain, subdirectory);
153
+ const firstAttemptUrl = _private.buildUrl(proto, customdomain, subdirectory);
154
154
 
155
- debug('Validating ' + url);
156
- return request.domain(url)
155
+ debug('Validating ' + firstAttemptUrl);
156
+ return request.domain(firstAttemptUrl)
157
157
  .then((result) => {
158
158
  debug('Response received');
159
159
  return _private.buildResponse(result, proto, subdomain, customdomain, subdirectory, prodomain);
160
160
  })
161
161
  .catch(() => {
162
- const url = _private.buildUrl('http', customdomain, subdirectory);
162
+ const secondAttemptUrl = _private.buildUrl('http', customdomain, subdirectory);
163
163
 
164
- debug('Validating ' + url);
165
- return request.domain(url)
164
+ debug('Validating ' + secondAttemptUrl);
165
+ return request.domain(secondAttemptUrl)
166
166
  .then((result) => {
167
167
  debug('HTTP response received');
168
168
  const response = _private.buildResponse(result, 'http', subdomain, customdomain, subdirectory, prodomain);
package/lib/request.js CHANGED
@@ -18,7 +18,7 @@ const staticLookup = () => async (hostname, _options, callback) => {
18
18
  const ips = await resolver.resolve(hostname);
19
19
 
20
20
  if (ips.length === 0) {
21
- return callback(new Error(`Unable to resolve ${hostname}`));
21
+ return callback(new errors.InternalServerError({message: `Unable to resolve ${hostname}`}));
22
22
  }
23
23
 
24
24
  if (nodeVersion >= 20) {
@@ -34,20 +34,27 @@ const staticLookup = () => async (hostname, _options, callback) => {
34
34
  };
35
35
 
36
36
  module.exports.domain = (url) => {
37
- const httpModule = new URL(url).protocol === 'https:' ? https : http;
38
- const httpAgent = new httpModule.Agent({lookup: staticLookup()});
39
-
37
+ /**
38
+ * @type {import('got').Options}
39
+ */
40
40
  const options = {
41
41
  // We want to capture details of different hops
42
42
  followRedirect: false,
43
43
  // Don't allow the request to go on for more than 15 seconds
44
- timeout: 15000,
44
+ timeout: {
45
+ request: 15000
46
+ },
45
47
  // If the network errors, retry 1 time:
46
- retries: 1,
48
+ retry: {
49
+ limit: 1
50
+ },
47
51
  headers: {
48
52
  'X-Ghost-Proxy': true
49
53
  },
50
- agent: httpAgent
54
+ agent: {
55
+ http: new http.Agent({lookup: staticLookup()}),
56
+ https: new https.Agent({lookup: staticLookup()})
57
+ }
51
58
  };
52
59
 
53
60
  const responses = [];
@@ -59,11 +66,11 @@ module.exports.domain = (url) => {
59
66
  const statusCode = res.statusCode;
60
67
  const headers = {};
61
68
 
62
- _.reduce(res.headers, (headers, value, name) => {
69
+ _.reduce(res.headers, (updatedHeaders = {}, value, name) => {
63
70
  if (_.startsWith(name, 'x-ghost') || _.includes(['server', 'location', 'x-amz-cf-id'], name)) {
64
- headers[name] = value;
71
+ updatedHeaders[name] = value;
65
72
  }
66
- return headers;
73
+ return updatedHeaders;
67
74
  }, headers);
68
75
 
69
76
  responses.push({statusCode, headers});
@@ -86,9 +93,12 @@ module.exports.domain = (url) => {
86
93
  .then(handleResponse)
87
94
  .then(() => resolve(responses))
88
95
  .catch((error) => {
89
- reject(new errors.BadRequestError(
90
- _.merge({message: 'Domain could not be requested', property: url, context: error.message}, error)
91
- ));
96
+ reject(new errors.BadRequestError({
97
+ message: 'Domain could not be requested',
98
+ property: url,
99
+ context: error.message,
100
+ code: error.code
101
+ }));
92
102
  });
93
103
  });
94
104
  };
package/lib/validate.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const _ = require('lodash');
2
2
  const dns = require('./dns');
3
3
  const pro = require('./pro');
4
+ const caa = require('./caa');
4
5
  const debug = require('@tryghost/debug')('dv:validate');
5
6
 
6
7
  const _private = {};
@@ -22,20 +23,28 @@ const validityReportObject = {
22
23
  dnsRating: null,
23
24
  isDnsValidForSSL: false,
24
25
  sslRating: null,
26
+ isCaaValid: true,
27
+ caaSource: null,
28
+ caaAllowedIssuers: [],
29
+ caaError: null,
25
30
  message: ''
26
31
  };
27
32
 
28
- _private.doChecks = (subdomain, customdomain, subdirectory, prodomain) => {
33
+ _private.doChecks = (subdomain, customdomain, subdirectory, prodomain, caaIssuer) => {
29
34
  const checkResult = {};
30
35
  debug('starting pro');
31
36
  return pro.validate('https', subdomain, customdomain, subdirectory, prodomain)
32
37
  .then((proResult) => {
33
38
  checkResult.pro = proResult;
34
39
  debug('got pro, starting dns');
35
- return dns.validate(subdomain, customdomain, checkResult.pro.isGhostPro, prodomain);
40
+ return Promise.all([
41
+ dns.validate(subdomain, customdomain, checkResult.pro.isGhostPro, prodomain),
42
+ caaIssuer ? caa.validate(customdomain, caaIssuer) : Promise.resolve(null)
43
+ ]);
36
44
  })
37
- .then((dnsResult) => {
45
+ .then(([dnsResult, caaResult]) => {
38
46
  checkResult.dns = dnsResult;
47
+ checkResult.caa = caaResult;
39
48
  debug('got dns, returning');
40
49
  return checkResult;
41
50
  });
@@ -43,16 +52,23 @@ _private.doChecks = (subdomain, customdomain, subdirectory, prodomain) => {
43
52
 
44
53
  /**
45
54
  * check health
46
- * @param {Object} blog
47
55
  *
48
56
  * - returns a validity report
49
57
  * - does NOT error unless something goes wrong
58
+ *
59
+ * @param {string} subdomain
60
+ * @param {string} customdomain
61
+ * @param {string} subdirectory
62
+ * @param {string} [prodomain=ghost.io]
63
+ * @param {string} [caaIssuer] - CA identity to check CAA records against (e.g. 'certainly.com').
64
+ * When omitted, CAA is not checked and isCaaValid defaults to true.
50
65
  */
51
- module.exports.validate = (subdomain, customdomain, subdirectory, prodomain = 'ghost.io') => _private
52
- .doChecks(subdomain, customdomain, subdirectory, prodomain)
66
+ module.exports.validate = (subdomain, customdomain, subdirectory, prodomain = 'ghost.io', caaIssuer) => _private
67
+ .doChecks(subdomain, customdomain, subdirectory, prodomain, caaIssuer)
53
68
  .then(function (result) {
54
- // Make a new validity report
55
- const validityReport = _.clone(validityReportObject);
69
+ // Deep clone so nested mutable fields (input, caaAllowedIssuers)
70
+ // can't leak across validation calls via the shared template.
71
+ const validityReport = _.cloneDeep(validityReportObject);
56
72
 
57
73
  // Store the inputted details
58
74
  validityReport.input.subdomain = subdomain;
@@ -64,8 +80,25 @@ module.exports.validate = (subdomain, customdomain, subdirectory, prodomain = 'g
64
80
  // Do a few last DNS-related checks, and then output validity values
65
81
  _.merge(validityReport, _.omit(result.dns, 'raw'));
66
82
 
83
+ if (result.caa) {
84
+ _.merge(validityReport, result.caa);
85
+
86
+ // CAA blocking issuance means SSL can't be provisioned going forward,
87
+ // even if DNS currently points at Ghost — treat it like a DNS failure.
88
+ if (!validityReport.isCaaValid) {
89
+ validityReport.isDnsValidForSSL = false;
90
+ validityReport.sslRating = 'D';
91
+ validityReport.message = 'CAA record does not allow ' + caaIssuer
92
+ + ' (allowed: ' + (validityReport.caaAllowedIssuers.join(', ') || 'none')
93
+ + ', source: ' + validityReport.caaSource + ')';
94
+ }
95
+ }
96
+
67
97
  if (validityReport.isProxy) {
68
- validityReport.message = validityReport.isProxyValid ? 'Found a valid proxy' : 'Found invalid proxy config';
98
+ const proxyMessage = validityReport.isProxyValid ? 'Found a valid proxy' : 'Found invalid proxy config';
99
+ validityReport.message = validityReport.message
100
+ ? validityReport.message + '; ' + proxyMessage
101
+ : proxyMessage;
69
102
  }
70
103
 
71
104
  validityReport.raw = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tryghost/domain-validator",
3
- "version": "1.6.0",
3
+ "version": "3.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "ssh://git@github.com/TryGhost/Pro",
@@ -25,18 +25,18 @@
25
25
  "test": "NODE_ENV=testing mocha -- $(find test -name '*.test.js')"
26
26
  },
27
27
  "devDependencies": {
28
- "mocha": "8.4.0",
28
+ "mocha": "11.7.5",
29
29
  "should": "13.2.3",
30
- "sinon": "9.2.4"
30
+ "sinon": "21.0.3"
31
31
  },
32
32
  "dependencies": {
33
- "@tryghost/debug": "0.1.17",
34
- "@tryghost/dns": "^0.5.1",
35
- "@tryghost/errors": "1.2.14",
36
- "@tryghost/pretty-cli": "1.2.9",
33
+ "@tryghost/debug": "2.0.3",
34
+ "@tryghost/dns": "^0.7.0",
35
+ "@tryghost/errors": "3.0.3",
36
+ "@tryghost/pretty-cli": "3.0.3",
37
37
  "bluebird": "3.7.2",
38
- "got": "8.3.2",
39
- "lodash": "4.17.21"
38
+ "got": "11.8.6",
39
+ "lodash": "4.17.23"
40
40
  },
41
- "gitHead": "3533c8bcfe1cc531fdc88955e3a8ce42ed7bc4fa"
41
+ "gitHead": "c5d1ebb864969de89c1e8a8dcac3f577bceee1dd"
42
42
  }