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 +7 -0
- package/lib/bimi/index.js +3 -2
- package/lib/tools.js +151 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
};
|