commit 2b2c60d6dfdb41c416f328b7d194be07734993ad Author: Reimar Date: Wed Jan 14 10:35:07 2026 +0100 Create DMARC validator diff --git a/assets/fonts/JetBrainsMono-Medium.ttf b/assets/fonts/JetBrainsMono-Medium.ttf new file mode 100644 index 0000000..9767115 Binary files /dev/null and b/assets/fonts/JetBrainsMono-Medium.ttf differ diff --git a/assets/fonts/JetBrainsMono-Medium.woff2 b/assets/fonts/JetBrainsMono-Medium.woff2 new file mode 100644 index 0000000..669d04c Binary files /dev/null and b/assets/fonts/JetBrainsMono-Medium.woff2 differ diff --git a/assets/fonts/OpenSans-Bold.ttf b/assets/fonts/OpenSans-Bold.ttf new file mode 100644 index 0000000..8570eee Binary files /dev/null and b/assets/fonts/OpenSans-Bold.ttf differ diff --git a/assets/fonts/OpenSans-Regular.ttf b/assets/fonts/OpenSans-Regular.ttf new file mode 100644 index 0000000..134d225 Binary files /dev/null and b/assets/fonts/OpenSans-Regular.ttf differ diff --git a/assets/images/background.png b/assets/images/background.png new file mode 100644 index 0000000..a1f5bd6 Binary files /dev/null and b/assets/images/background.png differ diff --git a/assets/scripts/ValidationError.js b/assets/scripts/ValidationError.js new file mode 100644 index 0000000..9595a16 --- /dev/null +++ b/assets/scripts/ValidationError.js @@ -0,0 +1 @@ +export class ValidationError extends Error {} diff --git a/assets/scripts/fields/ConstantField.js b/assets/scripts/fields/ConstantField.js new file mode 100644 index 0000000..1c069f1 --- /dev/null +++ b/assets/scripts/fields/ConstantField.js @@ -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}"`) + } +} diff --git a/assets/scripts/fields/DmarcUriListField.js b/assets/scripts/fields/DmarcUriListField.js new file mode 100644 index 0000000..21ac4a4 --- /dev/null +++ b/assets/scripts/fields/DmarcUriListField.js @@ -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; + } +} diff --git a/assets/scripts/fields/EnumField.js b/assets/scripts/fields/EnumField.js new file mode 100644 index 0000000..f3d7b1a --- /dev/null +++ b/assets/scripts/fields/EnumField.js @@ -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(", ")}`); + } +} diff --git a/assets/scripts/fields/Field.js b/assets/scripts/fields/Field.js new file mode 100644 index 0000000..44242c8 --- /dev/null +++ b/assets/scripts/fields/Field.js @@ -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; + } +} \ No newline at end of file diff --git a/assets/scripts/fields/IntField.js b/assets/scripts/fields/IntField.js new file mode 100644 index 0000000..0c08fe7 --- /dev/null +++ b/assets/scripts/fields/IntField.js @@ -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; + } +} diff --git a/assets/scripts/tools/DmarcTool.js b/assets/scripts/tools/DmarcTool.js new file mode 100644 index 0000000..3e01920 --- /dev/null +++ b/assets/scripts/tools/DmarcTool.js @@ -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; + } +} diff --git a/assets/scripts/tools/DnsTool.js b/assets/scripts/tools/DnsTool.js new file mode 100644 index 0000000..81f1e98 --- /dev/null +++ b/assets/scripts/tools/DnsTool.js @@ -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; + } +} diff --git a/assets/scripts/validator.js b/assets/scripts/validator.js new file mode 100644 index 0000000..1090592 --- /dev/null +++ b/assets/scripts/validator.js @@ -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; + } +} \ No newline at end of file diff --git a/assets/styles/main.css b/assets/styles/main.css new file mode 100644 index 0000000..ae2fc63 --- /dev/null +++ b/assets/styles/main.css @@ -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; +} diff --git a/dmarc-validator/index.html b/dmarc-validator/index.html new file mode 100644 index 0000000..efad72e --- /dev/null +++ b/dmarc-validator/index.html @@ -0,0 +1,76 @@ + + + + + DMARC Validator + + + + +

DMARC Validator

+ +
+ + +
+ +

Paste a DMARC DNS record into the input field to check if it is valid.

+ +

+ + +

+ +

+ + Validation Success +

+ +

+ +

+ 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. +

+ +

+ 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. +

+ +

+ 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. +

+ +

+ The most important fields are: +

+ + + +

+ A full list of rules can be found on the + DMARC specification page. +

+
+ +