Create DMARC validator
This commit is contained in:
commit
2b2c60d6df
BIN
assets/fonts/JetBrainsMono-Medium.ttf
Normal file
BIN
assets/fonts/JetBrainsMono-Medium.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/JetBrainsMono-Medium.woff2
Normal file
BIN
assets/fonts/JetBrainsMono-Medium.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/OpenSans-Bold.ttf
Normal file
BIN
assets/fonts/OpenSans-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/OpenSans-Regular.ttf
Normal file
BIN
assets/fonts/OpenSans-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/images/background.png
Normal file
BIN
assets/images/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 362 B |
1
assets/scripts/ValidationError.js
Normal file
1
assets/scripts/ValidationError.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export class ValidationError extends Error {}
|
||||||
13
assets/scripts/fields/ConstantField.js
Normal file
13
assets/scripts/fields/ConstantField.js
Normal 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}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
assets/scripts/fields/DmarcUriListField.js
Normal file
24
assets/scripts/fields/DmarcUriListField.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
assets/scripts/fields/EnumField.js
Normal file
16
assets/scripts/fields/EnumField.js
Normal 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(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
assets/scripts/fields/Field.js
Normal file
34
assets/scripts/fields/Field.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
assets/scripts/fields/IntField.js
Normal file
24
assets/scripts/fields/IntField.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
assets/scripts/tools/DmarcTool.js
Normal file
49
assets/scripts/tools/DmarcTool.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
assets/scripts/tools/DnsTool.js
Normal file
46
assets/scripts/tools/DnsTool.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
assets/scripts/validator.js
Normal file
39
assets/scripts/validator.js
Normal 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
102
assets/styles/main.css
Normal 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;
|
||||||
|
}
|
||||||
76
dmarc-validator/index.html
Normal file
76
dmarc-validator/index.html
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user