@nictool/dns-resource-record 1.7.0 → 1.8.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/lib/bind.js CHANGED
@@ -61,15 +61,17 @@ export function fromBind(rrInstance, opts) {
61
61
  }
62
62
 
63
63
  const fields = rrInstance.getFields('rdata')
64
+ const rdataDefs = rrInstance.constructor.rdataFields ?? []
64
65
  for (let i = 0; i < fields.length; i++) {
65
66
  const isLastField = i === fields.length - 1
66
67
  if (isLastField && rrInstance.isQuotedField(fields[i])) {
67
68
  // Collect all remaining tokens for TXT/etc
68
- const val = rdata
69
- .slice(i)
70
- .map((s) => s.replace(/^"|"$/g, ''))
71
- .join('')
72
- result[fields[i]] = val
69
+ const tokens = rdata.slice(i).map((s) => s.replace(/^"|"$/g, ''))
70
+ // For `charstrs` (TXT), preserve multi-string boundaries (RFC 1035 §3.3.14).
71
+ // For `qstr`/`qcharstr` (single character-string), join into one string.
72
+ const def = rdataDefs[i]
73
+ const fieldType = Array.isArray(def) ? def[1] : null
74
+ result[fields[i]] = fieldType === 'charstrs' && tokens.length > 1 ? tokens : tokens.join('')
73
75
  break
74
76
  }
75
77
 
package/lib/wire.js CHANGED
@@ -15,29 +15,59 @@ export function encodeName(name) {
15
15
  /**
16
16
  * Read a (possibly pointer-compressed) DNS name from a packet.
17
17
  * Returns { bytes: Buffer (uncompressed wire name), end: offset after name }.
18
+ *
19
+ * Bounded to defend against malicious or truncated packets:
20
+ * - rejects pointer cycles via a visited-offset set
21
+ * - caps total pointer dereferences at 128
22
+ * - caps the decoded name at 255 bytes (RFC 1035 §3.1)
23
+ * - rejects truncated pointers, pointer targets past the packet end,
24
+ * truncated labels, and unterminated names
18
25
  */
19
26
  export function readWireName(packet, offset) {
27
+ if (offset < 0 || offset >= packet.length) {
28
+ throw new Error('readWireName: offset out of bounds')
29
+ }
30
+
20
31
  const labels = []
32
+ const visited = new Set()
21
33
  let pos = offset
22
34
  let end = -1
35
+ let nameLen = 0
36
+ let terminated = false
23
37
 
24
38
  while (pos < packet.length) {
25
39
  const len = packet[pos]
26
40
  if (len === 0) {
27
41
  if (end === -1) end = pos + 1
28
42
  labels.push(Buffer.from([0]))
43
+ terminated = true
29
44
  break
30
45
  }
31
46
  if ((len & 0xc0) === 0xc0) {
47
+ if (pos + 1 >= packet.length) throw new Error('readWireName: truncated pointer')
32
48
  if (end === -1) end = pos + 2
33
- pos = ((len & 0x3f) << 8) | packet[pos + 1]
49
+ if (visited.has(pos)) throw new Error('readWireName: pointer cycle detected')
50
+ visited.add(pos)
51
+ if (visited.size > 128) throw new Error('readWireName: pointer chain too long')
52
+ const target = ((len & 0x3f) << 8) | packet[pos + 1]
53
+ if (target >= packet.length) throw new Error('readWireName: pointer target out of bounds')
54
+ pos = target
34
55
  continue
35
56
  }
57
+ // Top two bits not 00 → reserved (RFC 1035 §4.1.4 / RFC 6891)
58
+ if ((len & 0xc0) !== 0) throw new Error('readWireName: reserved label type')
59
+ if (len > 63) throw new Error('readWireName: label exceeds 63 bytes')
60
+ if (pos + 1 + len > packet.length) throw new Error('readWireName: truncated label')
36
61
  const label = packet.slice(pos, pos + 1 + len)
37
62
  labels.push(label)
63
+ // RFC 1035 §3.1: octets of the full domain name (labels + length octets +
64
+ // final zero) MUST NOT exceed 255 — count the root terminator too.
65
+ nameLen += 1 + len
66
+ if (nameLen + 1 > 255) throw new Error('readWireName: name exceeds 255 bytes')
38
67
  pos += 1 + len
39
68
  }
40
69
 
70
+ if (!terminated) throw new Error('readWireName: unterminated name')
41
71
  return { bytes: Buffer.concat(labels), end }
42
72
  }
43
73
 
@@ -207,6 +237,81 @@ export function wirePackDomain(fqdn) {
207
237
  return buf
208
238
  }
209
239
 
240
+ /**
241
+ * Pack a fully qualified domain name into wire format with RFC 1035 §4.1.4
242
+ * name compression. `dict` is a `Map<suffix, offset>` shared across all names
243
+ * emitted into a single outbound message; `currentOffset` is the absolute
244
+ * offset at which the caller intends to place the returned bytes.
245
+ *
246
+ * Walks labels from longest suffix to shortest: when a suffix already has an
247
+ * offset in `dict`, emits a 2-byte pointer and stops; otherwise emits the label
248
+ * verbatim and records the new suffix offset for later reuse.
249
+ */
250
+ export function wirePackDomainCompressed(fqdn, dict, currentOffset) {
251
+ if (fqdn === '.') return new Uint8Array([0])
252
+ const enc = new TextEncoder()
253
+ const labels = fqdn
254
+ .split('.')
255
+ .filter((p) => p.length > 0)
256
+ .map((p) => {
257
+ const b = enc.encode(p)
258
+ if (b.length > 63) throw new Error(`DNS label exceeds 63 bytes: ${p}`)
259
+ return b
260
+ })
261
+
262
+ // Pre-compute the absolute write offset of each label and the suffix it begins.
263
+ const suffixes = []
264
+ let prefixBytes = 0
265
+ for (let i = 0; i < labels.length; i++) {
266
+ suffixes.push({
267
+ key:
268
+ labels
269
+ .slice(i)
270
+ .map((b) => new TextDecoder().decode(b))
271
+ .join('.') + '.',
272
+ writeOffset: currentOffset + prefixBytes,
273
+ })
274
+ prefixBytes += 1 + labels[i].length
275
+ }
276
+
277
+ // Find the longest existing suffix in the dict; everything to its right gets
278
+ // replaced by a single pointer.
279
+ let pointerAt = labels.length
280
+ let pointerOffset = -1
281
+ for (let i = 0; i < suffixes.length; i++) {
282
+ const hit = dict.get(suffixes[i].key)
283
+ if (hit !== undefined && hit < 0x4000) {
284
+ pointerAt = i
285
+ pointerOffset = hit
286
+ break
287
+ }
288
+ }
289
+
290
+ // Compute output size and allocate.
291
+ let outSize = 0
292
+ for (let i = 0; i < pointerAt; i++) outSize += 1 + labels[i].length
293
+ outSize += pointerAt < labels.length ? 2 : 1 // pointer (2 bytes) or root terminator (1)
294
+ const buf = new Uint8Array(outSize)
295
+
296
+ let off = 0
297
+ for (let i = 0; i < pointerAt; i++) {
298
+ // Record this suffix for future reuse before writing past it.
299
+ if (suffixes[i].writeOffset < 0x4000 && !dict.has(suffixes[i].key)) {
300
+ dict.set(suffixes[i].key, suffixes[i].writeOffset)
301
+ }
302
+ buf[off++] = labels[i].length
303
+ buf.set(labels[i], off)
304
+ off += labels[i].length
305
+ }
306
+ if (pointerAt < labels.length) {
307
+ buf[off++] = 0xc0 | ((pointerOffset >> 8) & 0x3f)
308
+ buf[off] = pointerOffset & 0xff
309
+ } else {
310
+ buf[off] = 0
311
+ }
312
+ return buf
313
+ }
314
+
210
315
  /**
211
316
  * Get wire format rdata for an RR instance.
212
317
  * Derived classes should override this with direct RFC 1035 wire encoding.
@@ -332,13 +437,18 @@ export function fromWireGeneric(rrInstance, { owner, cls, ttl, rdata }) {
332
437
  break
333
438
  }
334
439
  case 'charstrs': {
440
+ // RFC 1035 §3.3.14: each <character-string> on the wire is len-prefixed.
441
+ // Preserve multi-string boundaries by returning an array when >1.
335
442
  const parts = []
336
443
  while (pos < rdata.length) {
337
444
  const strLen = rdata[pos++]
445
+ if (pos + strLen > rdata.length) {
446
+ throw new Error('fromWireGeneric: truncated character-string in rdata')
447
+ }
338
448
  parts.push(new TextDecoder().decode(rdata.subarray(pos, pos + strLen)))
339
449
  pos += strLen
340
450
  }
341
- result[fieldName] = parts.join('')
451
+ result[fieldName] = parts.length > 1 ? parts : (parts[0] ?? '')
342
452
  break
343
453
  }
344
454
  case 'svcparams':
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "@nictool/dns-resource-record",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "DNS Resource Records",
5
5
  "main": "index.js",
6
6
  "exports": {
7
7
  ".": {
8
8
  "browser": "./dist/dns-rr.min.js",
9
+ "import": "./index.js",
10
+ "require": "./dist/dns-rr.cjs",
9
11
  "default": "./index.js"
10
12
  }
11
13
  },
@@ -18,8 +20,9 @@
18
20
  ],
19
21
  "type": "module",
20
22
  "scripts": {
21
- "build": "npm run build:browser",
23
+ "build": "npm run build:browser && npm run build:cjs",
22
24
  "build:browser": "npx rollup -i index.js -o dist/dns-rr.js -f es && esbuild dist/dns-rr.js --minify --sourcemap --outfile=dist/dns-rr.min.js && rm dist/dns-rr.js",
25
+ "build:cjs": "npx rollup -i index.js -o dist/dns-rr.cjs -f cjs --exports named",
23
26
  "clean": "rm -rf coverage dist node_modules package-lock.json",
24
27
  "format:check": "npm run prettier; npm run lint",
25
28
  "format": "npm run prettier -- --write && npm run lint:fix",
@@ -58,7 +61,7 @@
58
61
  },
59
62
  "homepage": "https://github.com/NicTool/dns-resource-record#readme",
60
63
  "engines": {
61
- "node": ">=22"
64
+ "node": ">=20"
62
65
  },
63
66
  "browserslist": [
64
67
  "chrome >= 74",
@@ -68,8 +71,8 @@
68
71
  ],
69
72
  "devDependencies": {
70
73
  "@eslint/js": "^10.0.1",
71
- "eslint": "^10.2.1",
72
- "globals": "^17.5.0"
74
+ "eslint": "^10.4.0",
75
+ "globals": "^17.6.0"
73
76
  },
74
77
  "prettier": {
75
78
  "printWidth": 110,
package/rr/cert.js CHANGED
@@ -58,6 +58,14 @@ export default class CERT extends RR {
58
58
  this.throwHelp(`CERT: unknown cert type mnemonic: ${val}`)
59
59
  }
60
60
 
61
+ setKeyTag(val) {
62
+ this.setTypedValue('u16', 'key tag', val)
63
+ }
64
+
65
+ setAlgorithm(val) {
66
+ this.setTypedValue('u8', 'algorithm', val)
67
+ }
68
+
61
69
  setCertificate(val) {
62
70
  // certificate/CRL portion is represented in base 64 [16] and may be
63
71
  // divided into any number of white-space-separated substrings
package/rr/cname.js CHANGED
@@ -12,6 +12,11 @@ export default class CNAME extends RR {
12
12
  super(opts)
13
13
  }
14
14
 
15
+ /****** Resource record specific setters *******/
16
+ setCname(val) {
17
+ this.setTypedValue('fqdn', 'cname', val)
18
+ }
19
+
15
20
  getDescription() {
16
21
  return 'Canonical Name'
17
22
  }
package/rr/dname.js CHANGED
@@ -11,6 +11,11 @@ export default class DNAME extends RR {
11
11
  super(opts)
12
12
  }
13
13
 
14
+ /****** Resource record specific setters *******/
15
+ setTarget(val) {
16
+ this.setTypedValue('fqdn', 'target', val)
17
+ }
18
+
14
19
  getDescription() {
15
20
  return 'Delegation Name'
16
21
  }
package/rr/ds.js CHANGED
@@ -15,6 +15,14 @@ export default class DS extends RR {
15
15
  }
16
16
 
17
17
  /****** Resource record specific setters *******/
18
+ setKeyTag(val) {
19
+ this.setTypedValue('u16', 'key tag', val)
20
+ }
21
+
22
+ setDigest(val) {
23
+ this.setTypedValue('str', 'digest', val)
24
+ }
25
+
18
26
  setAlgorithm(val) {
19
27
  if (!this.getAlgorithmOptions().has(val)) this.throwHelp(`DS: algorithm invalid`)
20
28
 
@@ -22,19 +30,30 @@ export default class DS extends RR {
22
30
  }
23
31
 
24
32
  getAlgorithmOptions() {
33
+ // IANA DNSSEC Algorithm Numbers
34
+ // https://www.iana.org/assignments/dns-sec-alg-numbers/
25
35
  return new Map([
26
36
  [1, 'RSA/MD5'],
27
37
  [2, 'DH'],
28
38
  [3, 'DSA/SHA-1'],
29
39
  [4, 'EC'],
30
40
  [5, 'RSA/SHA-1'],
41
+ [6, 'DSA-NSEC3-SHA1'],
42
+ [7, 'RSASHA1-NSEC3-SHA1'],
43
+ [8, 'RSA/SHA-256'],
44
+ [10, 'RSA/SHA-512'],
45
+ [13, 'ECDSA P-256/SHA-256'],
46
+ [14, 'ECDSA P-384/SHA-384'],
47
+ [15, 'Ed25519'],
48
+ [16, 'Ed448'],
31
49
  [253, ''],
32
50
  [254, ''],
33
51
  ])
34
52
  }
35
53
 
36
54
  setDigestType(val) {
37
- if (![1, 2].includes(val)) this.throwHelp(`DS: digest type invalid`)
55
+ // 1=SHA-1 (RFC 4034), 2=SHA-256 (RFC 4509), 4=SHA-384 (RFC 6605)
56
+ if (![1, 2, 4].includes(val)) this.throwHelp(`DS: digest type invalid`)
38
57
 
39
58
  this.set('digest type', val)
40
59
  }
package/rr/mx.js CHANGED
@@ -23,6 +23,10 @@ export default class MX extends RR {
23
23
  this.set('preference', val)
24
24
  }
25
25
 
26
+ setExchange(val) {
27
+ this.setTypedValue('fqdn', 'exchange', val)
28
+ }
29
+
26
30
  getDescription() {
27
31
  return 'Mail Exchanger'
28
32
  }
package/rr/rrsig.js CHANGED
@@ -49,13 +49,51 @@ export default class RRSIG extends RR {
49
49
  this.set('algorithm', val)
50
50
  }
51
51
 
52
+ setLabels(val) {
53
+ this.setTypedValue('u8', 'labels', val)
54
+ }
55
+
56
+ setOriginalTtl(val) {
57
+ this.setTypedValue('u32', 'original ttl', val)
58
+ }
59
+
60
+ setSignatureExpiration(val) {
61
+ this.setTypedValue('u32', 'signature expiration', val)
62
+ }
63
+
64
+ setSignatureInception(val) {
65
+ this.setTypedValue('u32', 'signature inception', val)
66
+ }
67
+
68
+ setKeyTag(val) {
69
+ this.setTypedValue('u16', 'key tag', val)
70
+ }
71
+
72
+ setSignersName(val) {
73
+ this.setTypedValue('fqdn', 'signers name', val)
74
+ }
75
+
76
+ setSignature(val) {
77
+ this.setTypedValue('str', 'signature', val)
78
+ }
79
+
52
80
  getAlgorithmOptions() {
81
+ // IANA DNSSEC Algorithm Numbers
82
+ // https://www.iana.org/assignments/dns-sec-alg-numbers/
53
83
  return new Map([
54
84
  [1, 'RSA/MD5'],
55
85
  [2, 'DH'],
56
- [3, 'RRSIGA/SHA-1'],
86
+ [3, 'DSA/SHA-1'],
57
87
  [4, 'EC'],
58
88
  [5, 'RSA/SHA-1'],
89
+ [6, 'DSA-NSEC3-SHA1'],
90
+ [7, 'RSASHA1-NSEC3-SHA1'],
91
+ [8, 'RSA/SHA-256'],
92
+ [10, 'RSA/SHA-512'],
93
+ [13, 'ECDSA P-256/SHA-256'],
94
+ [14, 'ECDSA P-384/SHA-384'],
95
+ [15, 'Ed25519'],
96
+ [16, 'Ed448'],
59
97
  [253],
60
98
  [254],
61
99
  ])
package/rr/spf.js CHANGED
@@ -55,7 +55,11 @@ export default class SPF extends TXT {
55
55
  }
56
56
 
57
57
  toTinydns() {
58
- const rdata = TINYDNS.escapeOctal(new RegExp(/[\r\n\t:\\/]/, 'g'), this.get('data'))
58
+ // `data` may be a string or an array of <character-string>s (RFC 1035 §3.3.14).
59
+ // tinydns generic format stores rdata as a flat byte stream, so join here.
60
+ let data = this.get('data')
61
+ if (Array.isArray(data)) data = data.join('')
62
+ const rdata = TINYDNS.escapeOctal(new RegExp(/[\r\n\t:\\/]/, 'g'), data)
59
63
  return this.getTinydnsGeneric(rdata)
60
64
  }
61
65
  }
package/rr/srv.js CHANGED
@@ -17,6 +17,23 @@ export default class SRV extends RR {
17
17
  super(opts)
18
18
  }
19
19
 
20
+ /****** Resource record specific setters *******/
21
+ setPriority(val) {
22
+ this.setTypedValue('u16', 'priority', val)
23
+ }
24
+
25
+ setWeight(val) {
26
+ this.setTypedValue('u16', 'weight', val)
27
+ }
28
+
29
+ setPort(val) {
30
+ this.setTypedValue('u16', 'port', val)
31
+ }
32
+
33
+ setTarget(val) {
34
+ this.setTypedValue('fqdn', 'target', val)
35
+ }
36
+
20
37
  getDescription() {
21
38
  return 'Service'
22
39
  }
package/rr/sshfp.js CHANGED
@@ -17,6 +17,21 @@ export default class SSHFP extends RR {
17
17
  super(opts)
18
18
  }
19
19
 
20
+ /****** Resource record specific setters *******/
21
+ setAlgorithm(val) {
22
+ if (!this.getAlgorithmOptions().has(val)) this.throwHelp(`SSHFP: algorithm invalid`)
23
+ this.setTypedValue('u8', 'algorithm', val)
24
+ }
25
+
26
+ setFptype(val) {
27
+ if (!this.getFptypeOptions().has(val)) this.throwHelp(`SSHFP: fptype invalid`)
28
+ this.setTypedValue('u8', 'fptype', val)
29
+ }
30
+
31
+ setFingerprint(val) {
32
+ this.setTypedValue('hex', 'fingerprint', val)
33
+ }
34
+
20
35
  getAlgorithmOptions() {
21
36
  return new Map([
22
37
  [0, 'reserved'],
package/rr/txt.js CHANGED
@@ -62,15 +62,20 @@ export default class TXT extends RR {
62
62
  if (n != 16) this.throwHelp('TXT fromTinydns, invalid n')
63
63
 
64
64
  rdata = TINYDNS.octalToChar(rdata)
65
- let s = ''
66
- let len = rdata[0].charCodeAt(0)
67
- let pos = 1
65
+ // Walk RFC 1035 §3.3.14 len-prefixed <character-string> segments.
66
+ const parts = []
67
+ let pos = 0
68
68
  while (pos < rdata.length) {
69
- s += rdata.slice(pos, +(len + pos))
70
- pos = len + pos
71
- len = rdata.charCodeAt(pos + 1)
69
+ const len = rdata.charCodeAt(pos)
70
+ pos += 1
71
+ if (pos + len > rdata.length) {
72
+ this.throwHelp('TXT fromTinydnsGeneric: truncated character-string in rdata')
73
+ }
74
+ parts.push(rdata.slice(pos, pos + len))
75
+ pos += len
72
76
  }
73
- return [fqdn, s, ttl, ts, loc]
77
+ const data = parts.length > 1 ? parts : (parts[0] ?? '')
78
+ return [fqdn, data, ttl, ts, loc]
74
79
  }
75
80
 
76
81
  /****** EXPORTERS *******/
@@ -84,8 +89,31 @@ export default class TXT extends RR {
84
89
  }
85
90
 
86
91
  getWireRdata() {
87
- let data = this.get('data')
88
- if (Array.isArray(data)) data = data.join('')
92
+ // RFC 1035 §3.3.14: TXT rdata is one or more <character-string>s, each up
93
+ // to 255 bytes. An array preserves explicit boundaries between strings;
94
+ // each element MUST be <= 255 UTF-8 bytes, otherwise we would silently
95
+ // split it and the boundary the caller asked us to preserve would be lost.
96
+ // A single string is auto-chunked at 255-byte UTF-8 boundaries.
97
+ const data = this.get('data')
98
+ if (Array.isArray(data)) {
99
+ const enc = new TextEncoder()
100
+ const buffers = data.map((s, i) => {
101
+ if (enc.encode(s).length > 255) {
102
+ this.throwHelp(
103
+ `TXT: array element ${i} exceeds 255 bytes; split it yourself or pass a single string to auto-chunk`,
104
+ )
105
+ }
106
+ return packStringWire(s)
107
+ })
108
+ const total = buffers.reduce((n, b) => n + b.length, 0)
109
+ const out = new Uint8Array(total)
110
+ let off = 0
111
+ for (const b of buffers) {
112
+ out.set(b, off)
113
+ off += b.length
114
+ }
115
+ return out
116
+ }
89
117
  return packStringWire(data)
90
118
  }
91
119