@upyo/smtp 0.5.1 → 0.5.2

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/README.md CHANGED
@@ -115,6 +115,12 @@ Configuration options
115
115
  | `poolSize` | `number` | `5` | Maximum pool connections |
116
116
  | `dkim` | `DkimConfig` | | DKIM signing configuration |
117
117
 
118
+ For non-loopback hosts, SMTP authentication requires either an implicit TLS
119
+ connection (`secure: true`) or a successful STARTTLS upgrade (`secure: false`).
120
+ If the server does not advertise STARTTLS, delivery returns a failed receipt
121
+ without transmitting passwords or OAuth 2.0 access tokens. Cleartext
122
+ authentication to loopback hosts remains available for local development.
123
+
118
124
  ### `SmtpAuth`
119
125
 
120
126
  `SmtpAuth` is a discriminated union of three strategies.
package/dist/index.cjs CHANGED
@@ -374,14 +374,92 @@ const CRLF_LENGTH = 2;
374
374
  */
375
375
  const QUIT_TIMEOUT_MS = 5e3;
376
376
  /**
377
- * Whether a host refers to the local loopback interface, for which cleartext
378
- * OAuth 2.0 authentication is permitted (e.g. local testing and development).
377
+ * Parse an IPv4 address into its four octets.
379
378
  *
380
- * @param host The host to check.
381
- * @returns `true` if the host is a loopback address.
379
+ * @param address The IPv4 address to parse.
380
+ * @returns The parsed octets, or `null` if the address is invalid.
381
+ */
382
+ function parseIpv4Address(address) {
383
+ const octets = address.split(".");
384
+ if (octets.length !== 4 || !octets.every((octet) => /^(?:0|[1-9]\d{0,2})$/.test(octet) && Number(octet) <= 255)) return null;
385
+ return octets.map(Number);
386
+ }
387
+ /**
388
+ * Parse an IPv6 address into its eight 16-bit groups.
389
+ *
390
+ * @param address The IPv6 address to parse.
391
+ * @returns The parsed groups, or `null` if the address is invalid.
392
+ */
393
+ function parseIpv6Address(address) {
394
+ const compressionParts = address.split("::");
395
+ if (compressionParts.length > 2) return null;
396
+ function parseGroups(part) {
397
+ if (part === "") return [];
398
+ const tokens = part.split(":");
399
+ const groups = [];
400
+ for (const [index, token] of tokens.entries()) if (token.includes(".")) {
401
+ if (index !== tokens.length - 1) return null;
402
+ const octets = parseIpv4Address(token);
403
+ if (octets == null) return null;
404
+ groups.push(octets[0] << 8 | octets[1], octets[2] << 8 | octets[3]);
405
+ } else if (/^[0-9a-f]{1,4}$/.test(token)) groups.push(Number.parseInt(token, 16));
406
+ else return null;
407
+ return groups;
408
+ }
409
+ const left = parseGroups(compressionParts[0]);
410
+ const right = parseGroups(compressionParts[1] ?? "");
411
+ if (left == null || right == null) return null;
412
+ if (compressionParts.length === 1) return left.length === 8 ? left : null;
413
+ const omittedGroups = 8 - left.length - right.length;
414
+ if (omittedGroups < 1) return null;
415
+ return [
416
+ ...left,
417
+ ...Array.from({ length: omittedGroups }, () => 0),
418
+ ...right
419
+ ];
420
+ }
421
+ /**
422
+ * Whether an IP address refers to the local loopback interface.
423
+ *
424
+ * @param address The IPv4 or IPv6 address to check.
425
+ * @returns `true` if the address is a loopback address.
426
+ */
427
+ function isLoopbackAddress(address) {
428
+ let normalized = address.toLowerCase();
429
+ if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
430
+ const zoneIndex = normalized.indexOf("%");
431
+ if (zoneIndex >= 0) normalized = normalized.slice(0, zoneIndex);
432
+ const ipv4Octets = parseIpv4Address(normalized);
433
+ if (ipv4Octets != null) return ipv4Octets[0] === 127;
434
+ const ipv6Groups = parseIpv6Address(normalized);
435
+ if (ipv6Groups == null) return false;
436
+ return ipv6Groups.slice(0, 7).every((group) => group === 0) && ipv6Groups[7] === 1 || ipv6Groups.slice(0, 5).every((group) => group === 0) && ipv6Groups[5] === 65535 && ipv6Groups[6] >> 8 === 127;
437
+ }
438
+ /**
439
+ * Whether a configured host name or address represents a loopback endpoint.
440
+ *
441
+ * This is a fallback for sockets that do not expose their connected peer
442
+ * address. A connected peer address takes precedence so a misleading host
443
+ * name cannot bypass the TLS requirement.
444
+ *
445
+ * @param host The configured SMTP host.
446
+ * @returns `true` if the host represents a loopback endpoint.
382
447
  */
383
448
  function isLoopbackHost(host) {
384
- return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
449
+ const normalized = host.toLowerCase().replace(/\.$/, "");
450
+ return normalized === "localhost" || normalized.endsWith(".localhost") || isLoopbackAddress(normalized);
451
+ }
452
+ /**
453
+ * Whether the SMTP connection is local enough to permit cleartext
454
+ * authentication during development.
455
+ *
456
+ * @param socket The connected SMTP socket, if available.
457
+ * @param host The configured SMTP host.
458
+ * @returns `true` if the connected peer or fallback host is loopback.
459
+ */
460
+ function isLoopbackConnection(socket, host) {
461
+ const remoteAddress = socket?.remoteAddress;
462
+ return remoteAddress == null ? isLoopbackHost(host) : isLoopbackAddress(remoteAddress);
385
463
  }
386
464
  var SmtpConnection = class {
387
465
  socket = null;
@@ -565,8 +643,8 @@ var SmtpConnection = class {
565
643
  if (!auth) return;
566
644
  if (this.authenticated) return;
567
645
  if (!this.capabilities.some((cap) => cap.toUpperCase().startsWith("AUTH"))) throw new SmtpAuthError("Server does not support authentication.");
646
+ if (!(this.socket instanceof node_tls.TLSSocket) && !isLoopbackConnection(this.socket, this.config.host)) throw new SmtpAuthError("SMTP authentication requires a TLS-secured connection to protect credentials; use `secure: true` or STARTTLS.");
568
647
  if ("accessToken" in auth || "refreshToken" in auth) {
569
- if (!(this.socket instanceof node_tls.TLSSocket) && !isLoopbackHost(this.config.host)) throw new SmtpAuthError("OAuth 2.0 authentication requires a TLS-secured connection to protect the access token; use `secure: true` or STARTTLS.");
570
648
  const mechanism = auth.method ?? selectOAuth2Mechanism(this.capabilities);
571
649
  switch (mechanism) {
572
650
  case "xoauth2":
package/dist/index.js CHANGED
@@ -351,14 +351,92 @@ const CRLF_LENGTH = 2;
351
351
  */
352
352
  const QUIT_TIMEOUT_MS = 5e3;
353
353
  /**
354
- * Whether a host refers to the local loopback interface, for which cleartext
355
- * OAuth 2.0 authentication is permitted (e.g. local testing and development).
354
+ * Parse an IPv4 address into its four octets.
356
355
  *
357
- * @param host The host to check.
358
- * @returns `true` if the host is a loopback address.
356
+ * @param address The IPv4 address to parse.
357
+ * @returns The parsed octets, or `null` if the address is invalid.
358
+ */
359
+ function parseIpv4Address(address) {
360
+ const octets = address.split(".");
361
+ if (octets.length !== 4 || !octets.every((octet) => /^(?:0|[1-9]\d{0,2})$/.test(octet) && Number(octet) <= 255)) return null;
362
+ return octets.map(Number);
363
+ }
364
+ /**
365
+ * Parse an IPv6 address into its eight 16-bit groups.
366
+ *
367
+ * @param address The IPv6 address to parse.
368
+ * @returns The parsed groups, or `null` if the address is invalid.
369
+ */
370
+ function parseIpv6Address(address) {
371
+ const compressionParts = address.split("::");
372
+ if (compressionParts.length > 2) return null;
373
+ function parseGroups(part) {
374
+ if (part === "") return [];
375
+ const tokens = part.split(":");
376
+ const groups = [];
377
+ for (const [index, token] of tokens.entries()) if (token.includes(".")) {
378
+ if (index !== tokens.length - 1) return null;
379
+ const octets = parseIpv4Address(token);
380
+ if (octets == null) return null;
381
+ groups.push(octets[0] << 8 | octets[1], octets[2] << 8 | octets[3]);
382
+ } else if (/^[0-9a-f]{1,4}$/.test(token)) groups.push(Number.parseInt(token, 16));
383
+ else return null;
384
+ return groups;
385
+ }
386
+ const left = parseGroups(compressionParts[0]);
387
+ const right = parseGroups(compressionParts[1] ?? "");
388
+ if (left == null || right == null) return null;
389
+ if (compressionParts.length === 1) return left.length === 8 ? left : null;
390
+ const omittedGroups = 8 - left.length - right.length;
391
+ if (omittedGroups < 1) return null;
392
+ return [
393
+ ...left,
394
+ ...Array.from({ length: omittedGroups }, () => 0),
395
+ ...right
396
+ ];
397
+ }
398
+ /**
399
+ * Whether an IP address refers to the local loopback interface.
400
+ *
401
+ * @param address The IPv4 or IPv6 address to check.
402
+ * @returns `true` if the address is a loopback address.
403
+ */
404
+ function isLoopbackAddress(address) {
405
+ let normalized = address.toLowerCase();
406
+ if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
407
+ const zoneIndex = normalized.indexOf("%");
408
+ if (zoneIndex >= 0) normalized = normalized.slice(0, zoneIndex);
409
+ const ipv4Octets = parseIpv4Address(normalized);
410
+ if (ipv4Octets != null) return ipv4Octets[0] === 127;
411
+ const ipv6Groups = parseIpv6Address(normalized);
412
+ if (ipv6Groups == null) return false;
413
+ return ipv6Groups.slice(0, 7).every((group) => group === 0) && ipv6Groups[7] === 1 || ipv6Groups.slice(0, 5).every((group) => group === 0) && ipv6Groups[5] === 65535 && ipv6Groups[6] >> 8 === 127;
414
+ }
415
+ /**
416
+ * Whether a configured host name or address represents a loopback endpoint.
417
+ *
418
+ * This is a fallback for sockets that do not expose their connected peer
419
+ * address. A connected peer address takes precedence so a misleading host
420
+ * name cannot bypass the TLS requirement.
421
+ *
422
+ * @param host The configured SMTP host.
423
+ * @returns `true` if the host represents a loopback endpoint.
359
424
  */
360
425
  function isLoopbackHost(host) {
361
- return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
426
+ const normalized = host.toLowerCase().replace(/\.$/, "");
427
+ return normalized === "localhost" || normalized.endsWith(".localhost") || isLoopbackAddress(normalized);
428
+ }
429
+ /**
430
+ * Whether the SMTP connection is local enough to permit cleartext
431
+ * authentication during development.
432
+ *
433
+ * @param socket The connected SMTP socket, if available.
434
+ * @param host The configured SMTP host.
435
+ * @returns `true` if the connected peer or fallback host is loopback.
436
+ */
437
+ function isLoopbackConnection(socket, host) {
438
+ const remoteAddress = socket?.remoteAddress;
439
+ return remoteAddress == null ? isLoopbackHost(host) : isLoopbackAddress(remoteAddress);
362
440
  }
363
441
  var SmtpConnection = class {
364
442
  socket = null;
@@ -542,8 +620,8 @@ var SmtpConnection = class {
542
620
  if (!auth) return;
543
621
  if (this.authenticated) return;
544
622
  if (!this.capabilities.some((cap) => cap.toUpperCase().startsWith("AUTH"))) throw new SmtpAuthError("Server does not support authentication.");
623
+ if (!(this.socket instanceof TLSSocket) && !isLoopbackConnection(this.socket, this.config.host)) throw new SmtpAuthError("SMTP authentication requires a TLS-secured connection to protect credentials; use `secure: true` or STARTTLS.");
545
624
  if ("accessToken" in auth || "refreshToken" in auth) {
546
- if (!(this.socket instanceof TLSSocket) && !isLoopbackHost(this.config.host)) throw new SmtpAuthError("OAuth 2.0 authentication requires a TLS-secured connection to protect the access token; use `secure: true` or STARTTLS.");
547
625
  const mechanism = auth.method ?? selectOAuth2Mechanism(this.capabilities);
548
626
  switch (mechanism) {
549
627
  case "xoauth2":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upyo/smtp",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "SMTP transport for Upyo email library",
5
5
  "keywords": [
6
6
  "email",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "sideEffects": false,
55
55
  "peerDependencies": {
56
- "@upyo/core": "0.5.1"
56
+ "@upyo/core": "0.5.2"
57
57
  },
58
58
  "devDependencies": {
59
59
  "tsdown": "^0.12.7",