Use axio instead of fetch for acme HTTP-01 challenge

This commit is contained in:
Fang-Pen Lin
2025-11-21 10:30:53 -08:00
parent 5ad4aaf017
commit 3a6e287f1b
@@ -1,3 +1,5 @@
import axios, { AxiosError } from "axios";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { isPrivateIp } from "@app/lib/ip/ipRange"; import { isPrivateIp } from "@app/lib/ip/ipRange";
@@ -13,10 +15,6 @@ import {
import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas"; import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas";
import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types";
type FetchError = Error & {
code?: string;
};
type TPkiAcmeChallengeServiceFactoryDep = { type TPkiAcmeChallengeServiceFactoryDep = {
acmeChallengeDAL: Pick< acmeChallengeDAL: Pick<
TPkiAcmeChallengeDALFactory, TPkiAcmeChallengeDALFactory,
@@ -74,18 +72,20 @@ export const pkiAcmeChallengeServiceFactory = ({
// Notice: well, we are in a transaction, ideally we should not hold transaction and perform // Notice: well, we are in a transaction, ideally we should not hold transaction and perform
// a long running operation for long time. But assuming we are not performing a tons of // a long running operation for long time. But assuming we are not performing a tons of
// challenge validation at the same time, it should be fine. // challenge validation at the same time, it should be fine.
const challengeResponse = await fetch(challengeUrl, { const challengeResponse = await axios.get<string>(challengeUrl.toString(), {
// In case if we override the host in the development mode, still provide the original host in the header // In case if we override the host in the development mode, still provide the original host in the header
// to help the upstream server to validate the request // to help the upstream server to validate the request
headers: { Host: host }, headers: { Host: host },
signal: AbortSignal.timeout(timeoutMs) timeout: timeoutMs,
responseType: "text",
validateStatus: () => true
}); });
if (challengeResponse.status !== 200) { if (challengeResponse.status !== 200) {
throw new AcmeIncorrectResponseError({ throw new AcmeIncorrectResponseError({
message: `ACME challenge response is not 200: ${challengeResponse.status}` message: `ACME challenge response is not 200: ${challengeResponse.status}`
}); });
} }
const challengeResponseBody = await challengeResponse.text(); const challengeResponseBody: string = challengeResponse.data;
const thumbprint = challenge.auth.account.publicKeyThumbprint; const thumbprint = challenge.auth.account.publicKeyThumbprint;
const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`; const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`;
if (challengeResponseBody.trimEnd() !== expectedChallengeResponseBody) { if (challengeResponseBody.trimEnd() !== expectedChallengeResponseBody) {
@@ -96,35 +96,25 @@ export const pkiAcmeChallengeServiceFactory = ({
// TODO: we should retry the challenge validation a few times, but let's keep it simple for now // TODO: we should retry the challenge validation a few times, but let's keep it simple for now
await acmeChallengeDAL.markAsInvalidCascadeById(challengeId, tx); await acmeChallengeDAL.markAsInvalidCascadeById(challengeId, tx);
// Properly type and inspect the error // Properly type and inspect the error
if (exp instanceof TypeError && exp.message.includes("fetch failed")) { if (axios.isAxiosError(exp)) {
const { cause } = exp; const axiosError = exp as AxiosError;
let errors: Error[] = []; const errorCode = axiosError.code;
if (cause instanceof AggregateError) { const errorMessage = axiosError.message;
errors = cause.errors as Error[];
} else if (cause instanceof Error) { if (errorCode === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) {
errors = [cause]; return new AcmeConnectionError({ message: "Connection refused" });
} }
// eslint-disable-next-line no-unreachable-loop if (errorCode === "ENOTFOUND" || errorMessage.includes("ENOTFOUND")) {
for (const err of errors) { return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" });
// TODO: handle multiple errors, return a compound error instead of just the first error
const fetchError = err as FetchError;
if (fetchError.code === "ECONNREFUSED" || fetchError.message.includes("ECONNREFUSED")) {
return new AcmeConnectionError({ message: "Connection refused" });
}
if (fetchError.code === "ENOTFOUND" || fetchError.message.includes("ENOTFOUND")) {
return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" });
}
logger.error(exp, "Unknown error validating ACME challenge response");
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
} }
} else if (exp instanceof DOMException) { if (errorCode === "ECONNABORTED" || errorMessage.includes("timeout")) {
if (exp.name === "TimeoutError") {
logger.error(exp, "Connection timed out while validating ACME challenge response"); logger.error(exp, "Connection timed out while validating ACME challenge response");
return new AcmeConnectionError({ message: "Connection timed out" }); return new AcmeConnectionError({ message: "Connection timed out" });
} }
logger.error(exp, "Unknown error validating ACME challenge response"); logger.error(exp, "Unknown error validating ACME challenge response");
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
} else if (exp instanceof Error) { }
if (exp instanceof Error) {
logger.error(exp, "Error validating ACME challenge response"); logger.error(exp, "Error validating ACME challenge response");
} else { } else {
logger.error(exp, "Unknown error validating ACME challenge response"); logger.error(exp, "Unknown error validating ACME challenge response");