mailauth 4.9.0 → 4.9.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.9.2](https://github.com/postalsys/mailauth/compare/v4.9.1...v4.9.2) (2025-08-28)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * ZMS-262 remove control chars from record add support for mappers in validateTagValueRecord ([#95](https://github.com/postalsys/mailauth/issues/95)) ([42828a6](https://github.com/postalsys/mailauth/commit/42828a6cb38add3aed35881f102488f8143407cb))
9
+
10
+ ## [4.9.1](https://github.com/postalsys/mailauth/compare/v4.9.0...v4.9.1) (2025-08-27)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * ZMS-262: Add raw record sanitanization and validation util functions ([#93](https://github.com/postalsys/mailauth/issues/93)) ([e4842cf](https://github.com/postalsys/mailauth/commit/e4842cf222bd6db29f34c25434b5c38c44edefdc))
16
+
3
17
  ## [4.9.0](https://github.com/postalsys/mailauth/compare/v4.8.6...v4.9.0) (2025-08-21)
4
18
 
5
19
 
package/lib/bimi/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const { Buffer } = require('node:buffer');
4
4
  const crypto = require('node:crypto');
5
5
  const dns = require('node:dns');
6
- const { formatAuthHeaderRow, parseDkimHeaders, formatDomain, getAlignment } = require('../tools');
6
+ const { formatAuthHeaderRow, parseDkimHeaders, formatDomain, getAlignment, validateTagValueRecord } = require('../tools');
7
7
  const Joi = require('joi');
8
8
  //const packageData = require('../../package.json');
9
9
  const httpsSchema = Joi.string().uri({
@@ -184,7 +184,8 @@ const lookup = async data => {
184
184
  response.authority = recordData.parsed.a.value;
185
185
 
186
186
  // Apple Mail requires additional policy header values in Authentication-Results header
187
- response.status.policy = { authority: 'none', 'authority-uri': recordData.parsed.a.value }; // VMC has not been actually checked here yet, so authority is none
187
+ const authorityUriValidationObj = validateTagValueRecord(recordData.parsed.a.value, 'bimi');
188
+ response.status.policy = { authority: 'none', 'authority-uri': authorityUriValidationObj.sanitizedRecord }; // VMC has not been actually checked here yet, so authority is none
188
189
  }
189
190
 
190
191
  response.info = formatAuthHeaderRow('bimi', response.status);
package/lib/tools.js CHANGED
@@ -570,6 +570,171 @@ function getCurTime(timeValue) {
570
570
  return new Date();
571
571
  }
572
572
 
573
+ function parseTagValueRecord(record, options = {}) {
574
+ const {
575
+ requiredTags = [],
576
+ allowedTags = null, // null means allow all, array means restrict to these
577
+ caseSensitive = false,
578
+ strictMode = false, // if true, stops parsing on first malformed part
579
+ allowDuplicateKeys = true // if false, treats duplicate keys as errors
580
+ } = options;
581
+
582
+ let sanitized = (record || '')
583
+ .replace(/[\x00-\x1F]+/g, ' ') // control chars
584
+ .replace(/\\r\\n/g, '')
585
+ .replace(/\\n/g, '')
586
+ .replace(/\r?\n/g, '')
587
+ .replace(/\s+/g, ' ')
588
+ .trim();
589
+
590
+ // Split on semicolons
591
+ const parts = sanitized.split(';');
592
+ const tags = {};
593
+ const validPairs = [];
594
+ const errors = [];
595
+ const warnings = [];
596
+
597
+ for (let part of parts) {
598
+ part = part.trim();
599
+ if (!part) continue; // Skip empty parts
600
+
601
+ // Look for tag=value pattern
602
+ const equalIndex = part.indexOf('=');
603
+ if (equalIndex === -1) {
604
+ const error = `Malformed part (no equals sign): "${part}"`;
605
+ errors.push(error);
606
+ if (strictMode) break;
607
+ continue;
608
+ }
609
+
610
+ let key = part.substring(0, equalIndex).trim();
611
+ let value = part.substring(equalIndex + 1).trim();
612
+
613
+ const normalizedKey = caseSensitive ? key : key.toLowerCase();
614
+
615
+ // Validate key format (should be alphanumeric, may include hyphens/underscores)
616
+ if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
617
+ const error = `Invalid tag name: "${key}"`;
618
+ errors.push(error);
619
+ if (strictMode) break;
620
+ continue;
621
+ }
622
+
623
+ if (allowedTags && !allowedTags.includes(normalizedKey)) {
624
+ warnings.push(`Unknown/disallowed tag ignored: "${key}"`);
625
+ continue;
626
+ }
627
+
628
+ if (normalizedKey in tags) {
629
+ if (!allowDuplicateKeys) {
630
+ const error = `Duplicate tag not allowed: "${key}"`;
631
+ errors.push(error);
632
+ if (strictMode) break;
633
+ continue;
634
+ }
635
+
636
+ if (Array.isArray(tags[normalizedKey])) {
637
+ tags[normalizedKey].push(value);
638
+ } else {
639
+ tags[normalizedKey] = [tags[normalizedKey], value];
640
+ }
641
+ warnings.push(`Duplicate tag "${key}" found`);
642
+ } else {
643
+ tags[normalizedKey] = value;
644
+ }
645
+
646
+ validPairs.push([normalizedKey, value]);
647
+ }
648
+
649
+ for (const requiredTag of requiredTags) {
650
+ const normalizedRequired = caseSensitive ? requiredTag : requiredTag.toLowerCase();
651
+ if (!(normalizedRequired in tags)) {
652
+ errors.push(`Missing required tag: "${requiredTag}"`);
653
+ }
654
+ }
655
+
656
+ const sanitizedRecord = validPairs.map(([key, value]) => `${key}=${value}`).join('; ');
657
+
658
+ return {
659
+ tags,
660
+ errors,
661
+ warnings,
662
+ isValid: errors.length === 0,
663
+ sanitizedRecord,
664
+ originalRecord: record
665
+ };
666
+ }
667
+
668
+ function convertToASCII(value) {
669
+ return (value || '').replace(/[^\x20-\x7E]/g, '');
670
+ }
671
+
672
+ function validateTagValueRecord(record, recordType) {
673
+ const configs = {
674
+ BIMI: {
675
+ requiredTags: ['v', 'l', 'a'],
676
+ allowedTags: ['v', 'l', 'a'],
677
+ caseSensitive: false,
678
+ strictMode: true,
679
+ allowDuplicateKeys: false,
680
+ validators: {
681
+ v: value => (/^BIMI\d+$/i.test(value) ? null : `Version must match BIMI<digit>, got: ${value}`),
682
+ l: value => {
683
+ if (!value.trim()) return 'Location cannot be empty';
684
+ try {
685
+ const url = new URL(value.trim());
686
+ return url.protocol !== 'https:' ? 'Location must use HTTPS protocol' : null;
687
+ } catch (e) {
688
+ return `Invalid location URL: ${value}`;
689
+ }
690
+ },
691
+ a: value => {
692
+ if (!value.trim()) return 'Authority cannot be empty';
693
+ try {
694
+ const url = new URL(value.trim());
695
+ return url.protocol !== 'https:' ? 'Authority must use HTTPS protocol' : null;
696
+ } catch (e) {
697
+ return `Invalid authority URL: ${value}`;
698
+ }
699
+ }
700
+ },
701
+ mappers: {
702
+ v: value => convertToASCII(value)
703
+ }
704
+ }
705
+ };
706
+
707
+ const config = configs[recordType.toUpperCase()];
708
+ if (!config) {
709
+ throw new Error(`Unknown record type: ${recordType}`);
710
+ }
711
+
712
+ const parsed = parseTagValueRecord(record, config);
713
+
714
+ // Mappers run regardless whether the resulting parsed object is valid
715
+ if (config.mappers) {
716
+ for (const [tag, mapper] of Object.entries(config.mappers)) {
717
+ if (parsed.tags && tag in parsed.tags) {
718
+ parsed.tags[tag] = mapper(parsed.tags[tag]);
719
+ }
720
+ }
721
+ }
722
+
723
+ if (config.validators && parsed.isValid) {
724
+ for (const [tag, validator] of Object.entries(config.validators)) {
725
+ if (parsed.tags && tag in parsed.tags) {
726
+ const validationError = validator(parsed.tags[tag]);
727
+ if (validationError) {
728
+ parsed.errors.push(validationError);
729
+ }
730
+ }
731
+ }
732
+ parsed.isValid = parsed.errors.length === 0;
733
+ }
734
+
735
+ return parsed;
736
+ }
737
+
573
738
  module.exports = {
574
739
  writeToStream,
575
740
  parseHeaders,
@@ -598,5 +763,9 @@ module.exports = {
598
763
 
599
764
  getCurTime,
600
765
 
601
- TLDTS_OPTS
766
+ TLDTS_OPTS,
767
+
768
+ validateTagValueRecord,
769
+ parseTagValueRecord,
770
+ convertToASCII
602
771
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mailauth",
3
- "version": "4.9.0",
3
+ "version": "4.9.2",
4
4
  "description": "Email authentication library for Node.js",
5
5
  "main": "lib/mailauth.js",
6
6
  "scripts": {