Skip to main content

Cloudflare worker for DNS01 validation

This script allows to prove ownership of a domain for the purposes of getting a wildcard TLS certificate through ACME. While Traefik can connect to many DNS providers automatically, there is a risk that if Traefik is compromised by an attacker, the attacker will gain full access to the DNS provider account. By using this script instead, Traefik only has credentials with a very limited blast radius. The script is hosted on Cloudflare workers instead of on the application servers, so that a compromise of the application server does not lead to a disclosure of the Cloudflare credentials.

The script is intended to be used with Traefik, using the httpreq provider:

--certificatesresolvers.myresolver.acme.dnschallenge.provider=httpreq

HTTPREQ_ENDPOINT=https://dns01.connor.workers.dev

In the Cloudflare UI, you need to set the environment variable CLOUDFLARE_API_TOKEN. The script will use that token to access the Cloudflare API.

See also the corresponding Traefik configuration on this website.

worker.js
addEventListener('fetch', (event) => {
event.respondWith(handleRequest(event.request));
});

// These credentials need to be given to Traefik
const BASIC_USER = 'user';
const BASIC_PASS = '<your chosen password>';

async function handleRequest(request) {
if (!request.headers.has('Authorization')) {
return new Response('Basic Auth required', {
status: 401,
});
}
// Throws exception when authorization fails.
const { user, pass } = basicAuthentication(request);
verifyCredentials(user, pass);

let { fqdn, value } = await request.json();
if (
typeof fqdn !== 'string' ||
!fqdn ||
typeof value !== 'string' ||
!value
) {
throw new BadRequestException('Invalid payload');
}
if (!fqdn.startsWith('_acme-challenge.')) {
throw new BadRequestException('Invalid domain');
}
if (fqdn.endsWith('.')) {
fqdn = fqdn.substring(0, fqdn.length - 1);
}

const url = new URL(request.url);

if (url.pathname === '/present') {
await createDnsRecord(fqdn, value);
console.log(`Successfully created DNS record for ${fqdn}`);
} else if (url.pathname === '/cleanup') {
await deleteDnsRecord(fqdn, value);
console.log(`Successfully deleted DNS record ${fqdn}`);
} else {
throw new BadRequestException('Invalid path');
}

return new Response('OK', {
status: 200,
headers: {
'Cache-Control': 'no-store',
},
});
}

async function createDnsRecord(fqdn, value) {
const zoneId = await getZoneIdForDomain(fqdn);
await createDnsRecordInZone(zoneId, fqdn, value);
}

async function deleteDnsRecord(fqdn, value) {
const zoneId = await getZoneIdForDomain(fqdn);
await deleteDnsRecordInZone(zoneId, fqdn, value);
}

async function getZoneIdForDomain(domain) {
const response = await api('zones/?per_page=50');

const zone = response.find((zone) => domain.endsWith(zone.name));

if (!zone) {
console.error(
`Did not find the zone for domain "${domain}" in the account. Available zones:`,
response.map((r) => r.name)
);
throw new ServerError();
}

return zone.id;
}

async function createDnsRecordInZone(zoneId, fqdn, value) {
await api(
'zones/' + encodeURIComponent(zoneId) + '/dns_records/',
'POST',
JSON.stringify({
type: 'TXT',
content: value,
name: fqdn,
ttl: 60,
})
);
}

async function deleteDnsRecordInZone(zoneId, fqdn, value) {
const record = (
await api(
`zones/${zoneId}/dns_records?name=${encodeURIComponent(
fqdn
)}&content=${encodeURIComponent(value)}&type=TXT`
)
)[0];

if (!record) {
console.error('Did not find the record in the zone');
throw new ServerError();
}

await api(`zones/${zoneId}/dns_records/${record.id}`, 'DELETE');
}

async function api(path, method, body) {
const response = await (
await fetch('https://api.cloudflare.com/client/v4/' + path, {
method,
headers: {
Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
'Content-Type': 'application/json',
},
body,
})
).json();

if (!response.success) {
console.error(response.errors);
throw new ServerError();
}

return response.result;
}

function verifyCredentials(user, pass) {
if (BASIC_USER !== user) {
throw new UnauthorizedException('Invalid credentials.');
}

if (BASIC_PASS !== pass) {
throw new UnauthorizedException('Invalid credentials.');
}
}

function basicAuthentication(request) {
const Authorization = request.headers.get('Authorization');

const [scheme, encoded] = Authorization.split(' ');

// The Authorization header must start with Basic, followed by a space.
if (!encoded || scheme !== 'Basic') {
throw new BadRequestException('Malformed authorization header.');
}

// Decodes the base64 value and performs unicode normalization.
// @see https://datatracker.ietf.org/doc/html/rfc7613#section-3.3.2 (and #section-4.2.2)
// @see https://dev.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/normalize
const buffer = Uint8Array.from(atob(encoded), (character) =>
character.charCodeAt(0)
);
const decoded = new TextDecoder().decode(buffer).normalize();

// The username & password are split by the first colon.
//=> example: "username:password"
const index = decoded.indexOf(':');

// The user & password are split by the first colon and MUST NOT contain control characters.
// @see https://tools.ietf.org/html/rfc5234#appendix-B.1 (=> "CTL = %x00-1F / %x7F")
if (index === -1 || /[\0-\x1F\x7F]/.test(decoded)) {
throw new BadRequestException('Invalid authorization value.');
}

return {
user: decoded.substring(0, index),
pass: decoded.substring(index + 1),
};
}

function UnauthorizedException(reason) {
this.status = 401;
this.statusText = 'Unauthorized';
this.reason = reason;
}

function BadRequestException(reason) {
this.status = 400;
this.statusText = 'Bad Request';
this.reason = reason;
}

function ServerError() {
this.status = 500;
this.statusText = 'Internal Server Error';
}