Create DMARC validator

This commit is contained in:
Reimar 2026-01-14 10:35:07 +01:00
commit 2b2c60d6df
Signed by: Reimar
GPG Key ID: 93549FA07F0AE268
16 changed files with 424 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

View File

@ -0,0 +1 @@
export class ValidationError extends Error {}

View File

@ -0,0 +1,13 @@
import { Field } from "./Field.js";
import { ValidationError } from "../ValidationError.js";
export class ConstantField extends Field {
constructor(key, value) {
super(key);
this.value = value;
}
validate(value) {
if (this.value !== value) throw new ValidationError(`Field ${this.key} must be "${this.value}"`)
}
}

View File

@ -0,0 +1,24 @@
import { Field } from "./Field.js";
import { ValidationError } from "../ValidationError.js";
export class DmarcUriListField extends Field {
constructor(key) {
super(key);
}
validate(value) {
const uris = value.split(",");
for (let uri of uris) {
uri = uri.replace(/!\d+[kmgt]$/);
try {
new URL(uri);
} catch(e) {
throw new ValidationError(`Invalid URI for field "${this.key}": ${uri}`);
}
}
return true;
}
}

View File

@ -0,0 +1,16 @@
import { Field } from "./Field.js";
import { ValidationError } from "../ValidationError.js";
export class EnumField extends Field {
constructor(key, values) {
super(key);
this.values = values;
}
validate(value) {
if (this.values.includes(value))
return true;
throw new ValidationError(`Invalid value for field "${this.key}" - must be one of: ${this.values.join(", ")}`);
}
}

View File

@ -0,0 +1,34 @@
export class Field {
isRequired = false;
defaultValue = null;
requiredIndex = null;
afterFieldName = null;
constructor(key) {
this.key = key;
}
validate() {
return true;
}
required() {
this.isRequired = true;
return this;
}
default(value) {
this.defaultValue = value;
return this;
}
atIndex(i) {
this.requiredIndex = i;
return this;
}
after(field) {
this.afterFieldName = field;
return this;
}
}

View File

@ -0,0 +1,24 @@
import { Field } from "./Field.js";
import { ValidationError } from "../ValidationError.js";
export class IntField extends Field {
constructor(key, min, max) {
super(key);
this.min = min;
this.max = max;
}
validate(value) {
const number = parseFloat(value);
if (isNaN(number)) throw new ValidationError(`Field "${this.key}" must be a number`);
if (!Number.isInteger(number)) throw new ValidationError(`Field "${this.key}" must be an integer`);
if (number < this.min) throw new ValidationError(`Field "${this.key}" must be ${this.min} or greater`);
if (number > this.max) throw new ValidationError(`Field "${this.key}" must not exceed ${this.max}`);
return true;
}
}

View File

@ -0,0 +1,49 @@
import { ConstantField } from "../fields/ConstantField.js";
import { EnumField } from "../fields/EnumField.js";
import { IntField } from "../fields/IntField.js";
import { Field } from "../fields/Field.js";
import { DmarcUriListField } from "../fields/DmarcUriListField.js";
import { DnsTool } from "./DnsTool.js";
import { ValidationError } from "../ValidationError.js";
export class DmarcTool extends DnsTool {
fields = [
new ConstantField("v", "DMARC1").required().atIndex(0),
new EnumField("p", ["none", "quarantine", "reject"]).required().atIndex(1).after("v"),
new EnumField("sp", ["none", "quarantine", "reject"]),
new IntField("pct", 0, 100).default(100),
new EnumField("adkim", ["r", "s"]).default("r"),
new EnumField("aspf", ["r", "s"]).default("r"),
new EnumField("fo", ["0", "1", "d", "s"]).default("0"),
new Field("rf").default("afrf"),
new IntField("ri", 0, 2 ** 32).default(86400),
new DmarcUriListField("rua"),
new DmarcUriListField("ruf"),
];
constructor(text) {
super(text);
}
tokenize() {
return this.text.split(/;\s*/);
}
getKeyValues() {
const result = [];
for (const token of this.tokenize()) {
if (token === "") continue;
const [key, value] = token.split("=");
if (!value) {
throw new ValidationError(`Field "${key}" is missing a value`);
}
result.push({ key, value });
}
return result;
}
}

View File

@ -0,0 +1,46 @@
import { ValidationError } from "../ValidationError.js";
export class DnsTool {
fields = [];
constructor(text) {
this.text = text;
}
getKeyValues() {
throw new Error("Unimplemented");
}
validate() {
const values = this.getKeyValues();
// Validate field order
for (const field of this.fields) {
const valueIdx = values.findIndex(v => v.key === field.key);
if (field.isRequired && valueIdx === -1) {
throw new ValidationError(`Field "${field.key}" is required`);
}
if (field.requiredIndex && field.requiredIndex !== valueIdx) {
if (field.requiredIndex === 0) throw new ValidationError(`Field "${field.key}" must come first`);
if (field.afterFieldName) throw new ValidationError(`Field "${field.key}" must come after "${field.afterFieldName}"`);
throw new ValidationError(`Field "${field.key}" must be at position ${field.requiredIndex + 1}`);
}
}
// Validate field values
for (let i = 0; i < values.length; i++) {
const input = values[i];
const field = this.fields.find(f => f.key === input.key);
if (!field) {
throw new ValidationError(`Unknown field: ${input.key}`)
}
field.validate(input.value);
}
return true;
}
}

View File

@ -0,0 +1,39 @@
import { DmarcTool } from "./tools/DmarcTool.js";
const tools = {
"/dmarc-validator": DmarcTool,
};
const Tool = tools[location.pathname];
document.getElementById("input").oninput = event => validate(event.target.value);
if (document.getElementById("input").value !== "") {
validate(document.getElementById("input").value);
}
function validate(value) {
document.getElementById("input").classList.remove("valid", "invalid");
document.getElementById("error").style.display = "none"
document.getElementById("success").style.display = "none";
document.getElementById("result-placeholder").style.display = "none";
if (!value) {
document.getElementById("result-placeholder").style.display = "flex";
return;
}
const tool = new Tool(value);
try {
tool.validate();
document.getElementById("input").classList.add("valid");
document.getElementById("success").style.display = "flex";
} catch (e) {
document.getElementById("input").classList.add("invalid");
document.getElementById("error").style.display = "flex";
document.getElementById("error-message").innerText = e.message;
}
}

102
assets/styles/main.css Normal file
View File

@ -0,0 +1,102 @@
@font-face {
font-family: "JetBrains Mono";
src:
url("/assets/fonts/JetBrainsMono-Medium.woff2") format("woff2"),
url("/assets/fonts/JetBrainsMono-Medium.ttf") format("truetype");
}
@font-face {
font-family: "Open Sans";
font-weight: normal;
src: url("/assets/fonts/OpenSans-Regular.ttf") format("truetype");
}
@font-face {
font-family: "Open Sans";
font-weight: bold;
src: url("/assets/fonts/OpenSans-Bold.ttf") format("truetype");
}
body {
background-image: url("/assets/images/background.png");
background-repeat: repeat;
background-color: #EEE;
font-family: "Open Sans", sans-serif;
max-width: 800px;
margin: auto;
}
h1 {
text-align: center;
}
label {
color: #757575;
font-size: 0.8rem;
}
#input {
font-family: "JetBrains Mono", monospace;
border: 2px solid black;
font-size: 1rem;
padding: 1rem 1.5rem;
width: 100%;
box-sizing: border-box;
transition: all 200ms;
}
#input:hover {
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.1);
}
#input:focus {
outline: none;
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.1);
}
#input.valid {
background-color: #C8E6C9;
}
#input.invalid {
background-color: #FFCDD2;
}
main {
background-color: white;
border: 1px solid #BDBDBD;
padding: 1rem 2rem;
margin-top: 2rem;
color: #424242;
font-size: 0.8rem;
}
a {
color: #039BE5;
}
li:not(:first-of-type) {
margin-top: 0.75rem;
}
.validation-result {
font-size: 1rem;
display: none;
gap: 0.5rem;
justify-content: center;
height: 1.4rem;
margin: 2rem auto;
font-weight: bold;
}
#error {
color: #E53935;
}
#success {
color: #43A047;
}
#result-placeholder {
display: flex;
}

View File

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>DMARC Validator</title>
<link rel="stylesheet" href="/assets/styles/main.css">
<script type="module" src="/assets/scripts/validator.js"></script>
</head>
<body>
<h1>DMARC Validator</h1>
<label for="input">DNS Record</label><br>
<input id="input" type="text" placeholder="v=DMARC1; ..." autocapitalize="off" spellcheck="false">
<main>
<h2 style="text-align: center;">Paste a DMARC DNS record into the input field to check if it is valid.</h2>
<p id="error" class="validation-result">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path fill="currentColor" d="M320 576C461.4 576 576 461.4 576 320C576 178.6 461.4 64 320 64C178.6 64 64 178.6 64 320C64 461.4 178.6 576 320 576zM231 231C240.4 221.6 255.6 221.6 264.9 231L319.9 286L374.9 231C384.3 221.6 399.5 221.6 408.8 231C418.1 240.4 418.2 255.6 408.8 264.9L353.8 319.9L408.8 374.9C418.2 384.3 418.2 399.5 408.8 408.8C399.4 418.1 384.2 418.2 374.9 408.8L319.9 353.8L264.9 408.8C255.5 418.2 240.3 418.2 231 408.8C221.7 399.4 221.6 384.2 231 374.9L286 319.9L231 264.9C221.6 255.5 221.6 240.3 231 231z"/></svg>
<span id="error-message"></span>
</p>
<p id="success" class="validation-result">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path fill="currentColor" d="M320 576C178.6 576 64 461.4 64 320C64 178.6 178.6 64 320 64C461.4 64 576 178.6 576 320C576 461.4 461.4 576 320 576zM438 209.7C427.3 201.9 412.3 204.3 404.5 215L285.1 379.2L233 327.1C223.6 317.7 208.4 317.7 199.1 327.1C189.8 336.5 189.7 351.7 199.1 361L271.1 433C276.1 438 282.9 440.5 289.9 440C296.9 439.5 303.3 435.9 307.4 430.2L443.3 243.2C451.1 232.5 448.7 217.5 438 209.7z"/></svg>
<span>Validation Success</span>
</p>
<p id="result-placeholder" class="validation-result"></p>
<p>
DMARC is a standard for web servers to tell how to handle validation errors in SPF and DKIM.
It is a DNS TXT record with different values, defining rules on when to reject the email, how to report
failures etc.
</p>
<p>
The TXT record itself must be on the _dmarc subdomain of the email domain. E.g. emails from example.com
must have a DMARC record at _dmarc.example.com.
</p>
<p>
The content is a list of fields, separated by a semicolon. It must always start with "v=DMARC1" so the
DMARC record can be easily identified.
</p>
<p>
The most important fields are:
</p>
<ul>
<li>
<b>p</b>: Tells what to do with emails that fail validation. Values can be "none" (do nothing),
"quarantine" (treat email as suspicious - e.g. place it in the spam folder) or
"reject" (outright discard the email upon receival). This field is required and must be placed after
the "v=DMARC1" field.
</li>
<li>
<b>adkim</b>: Tells how strictly to handle DKIM validation errors. Values can be "r" (relaxed, this
is the default) or "s" (strict).
</li>
<li>
<b>aspf</b>: Tells how strictly to handle SPF validation errors. Like adkim, values can be "r"
(relaxed, this is the default) or "s" (strict).
</li>
</ul>
<p>
A full list of rules can be found on the
<a href="https://datatracker.ietf.org/doc/html/rfc7489#section-6.3" target="_blank">DMARC specification page</a>.
</p>
</main>
</body>
</html>