mailauth 4.9.0 → 4.9.1

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.9.1](https://github.com/postalsys/mailauth/compare/v4.9.0...v4.9.1) (2025-08-27)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * 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))
9
+
3
10
  ## [4.9.0](https://github.com/postalsys/mailauth/compare/v4.8.6...v4.9.0) (2025-08-21)
4
11
 
5
12
 
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,153 @@ 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(/\\r\\n/g, '') // Remove literal \r\n
584
+ .replace(/\\n/g, '') // Remove literal \n
585
+ .replace(/\r?\n/g, '') // Remove actual newlines
586
+ .trim();
587
+
588
+ // Split on semicolons
589
+ const parts = sanitized.split(';');
590
+ const tags = {};
591
+ const validPairs = [];
592
+ const errors = [];
593
+ const warnings = [];
594
+
595
+ for (let part of parts) {
596
+ part = part.trim();
597
+ if (!part) continue; // Skip empty parts
598
+
599
+ // Look for tag=value pattern
600
+ const equalIndex = part.indexOf('=');
601
+ if (equalIndex === -1) {
602
+ const error = `Malformed part (no equals sign): "${part}"`;
603
+ errors.push(error);
604
+ if (strictMode) break;
605
+ continue;
606
+ }
607
+
608
+ let key = part.substring(0, equalIndex).trim();
609
+ let value = part.substring(equalIndex + 1).trim();
610
+
611
+ const normalizedKey = caseSensitive ? key : key.toLowerCase();
612
+
613
+ // Validate key format (should be alphanumeric, may include hyphens/underscores)
614
+ if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
615
+ const error = `Invalid tag name: "${key}"`;
616
+ errors.push(error);
617
+ if (strictMode) break;
618
+ continue;
619
+ }
620
+
621
+ if (allowedTags && !allowedTags.includes(normalizedKey)) {
622
+ warnings.push(`Unknown/disallowed tag ignored: "${key}"`);
623
+ continue;
624
+ }
625
+
626
+ if (normalizedKey in tags) {
627
+ if (!allowDuplicateKeys) {
628
+ const error = `Duplicate tag not allowed: "${key}"`;
629
+ errors.push(error);
630
+ if (strictMode) break;
631
+ continue;
632
+ }
633
+
634
+ if (Array.isArray(tags[normalizedKey])) {
635
+ tags[normalizedKey].push(value);
636
+ } else {
637
+ tags[normalizedKey] = [tags[normalizedKey], value];
638
+ }
639
+ warnings.push(`Duplicate tag "${key}" found`);
640
+ } else {
641
+ tags[normalizedKey] = value;
642
+ }
643
+
644
+ validPairs.push([normalizedKey, value]);
645
+ }
646
+
647
+ for (const requiredTag of requiredTags) {
648
+ const normalizedRequired = caseSensitive ? requiredTag : requiredTag.toLowerCase();
649
+ if (!(normalizedRequired in tags)) {
650
+ errors.push(`Missing required tag: "${requiredTag}"`);
651
+ }
652
+ }
653
+
654
+ const sanitizedRecord = validPairs.map(([key, value]) => `${key}=${value}`).join('; ');
655
+
656
+ return {
657
+ tags,
658
+ errors,
659
+ warnings,
660
+ isValid: errors.length === 0,
661
+ sanitizedRecord,
662
+ originalRecord: record
663
+ };
664
+ }
665
+
666
+ function validateTagValueRecord(record, recordType) {
667
+ const configs = {
668
+ BIMI: {
669
+ requiredTags: ['v', 'l', 'a'],
670
+ allowedTags: ['v', 'l', 'a'],
671
+ caseSensitive: false,
672
+ strictMode: true,
673
+ allowDuplicateKeys: false,
674
+ validators: {
675
+ v: value => (/^BIMI\d+$/i.test(value) ? null : `Version must match BIMI<digit>, got: ${value}`),
676
+ l: value => {
677
+ if (!value.trim()) return 'Location cannot be empty';
678
+ try {
679
+ const url = new URL(value.trim());
680
+ return url.protocol !== 'https:' ? 'Location must use HTTPS protocol' : null;
681
+ } catch (e) {
682
+ return `Invalid location URL: ${value}`;
683
+ }
684
+ },
685
+ a: value => {
686
+ if (!value.trim()) return 'Authority cannot be empty';
687
+ try {
688
+ const url = new URL(value.trim());
689
+ return url.protocol !== 'https:' ? 'Authority must use HTTPS protocol' : null;
690
+ } catch (e) {
691
+ return `Invalid authority URL: ${value}`;
692
+ }
693
+ }
694
+ }
695
+ }
696
+ };
697
+
698
+ const config = configs[recordType.toUpperCase()];
699
+ if (!config) {
700
+ throw new Error(`Unknown record type: ${recordType}`);
701
+ }
702
+
703
+ const parsed = parseTagValueRecord(record, config);
704
+
705
+ if (config.validators && parsed.isValid) {
706
+ for (const [tag, validator] of Object.entries(config.validators)) {
707
+ if (parsed.tags && tag in parsed.tags) {
708
+ const validationError = validator(parsed.tags[tag]);
709
+ if (validationError) {
710
+ parsed.errors.push(validationError);
711
+ }
712
+ }
713
+ }
714
+ parsed.isValid = parsed.errors.length === 0;
715
+ }
716
+
717
+ return parsed;
718
+ }
719
+
573
720
  module.exports = {
574
721
  writeToStream,
575
722
  parseHeaders,
@@ -598,5 +745,8 @@ module.exports = {
598
745
 
599
746
  getCurTime,
600
747
 
601
- TLDTS_OPTS
748
+ TLDTS_OPTS,
749
+
750
+ validateTagValueRecord,
751
+ parseTagValueRecord
602
752
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mailauth",
3
- "version": "4.9.0",
3
+ "version": "4.9.1",
4
4
  "description": "Email authentication library for Node.js",
5
5
  "main": "lib/mailauth.js",
6
6
  "scripts": {