Add HTTP headers checker and tests
This commit is contained in:
+350
-54
@@ -2,16 +2,21 @@
|
||||
// Tool tab navigation
|
||||
// ---------------------------------------------------------
|
||||
|
||||
// Find all tool buttons and tool panels.
|
||||
const toolTabs = document.querySelectorAll(".tool-tab");
|
||||
const toolPanels = document.querySelectorAll(".tool-panel");
|
||||
|
||||
// Switch tools when a tab button is clicked.
|
||||
toolTabs.forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
const selectedToolId = tab.dataset.tool;
|
||||
|
||||
// Update the active tab.
|
||||
// Mark only the selected tab as active.
|
||||
toolTabs.forEach((currentTab) => {
|
||||
currentTab.classList.toggle("active", currentTab === tab);
|
||||
currentTab.classList.toggle(
|
||||
"active",
|
||||
currentTab === tab
|
||||
);
|
||||
});
|
||||
|
||||
// Show only the selected tool panel.
|
||||
@@ -29,58 +34,82 @@ toolTabs.forEach((tab) => {
|
||||
const uptimeForm = document.getElementById("uptime-form");
|
||||
const urlInput = document.getElementById("url-input");
|
||||
const checkButton = document.getElementById("check-button");
|
||||
const statusMessage = document.getElementById("status-message");
|
||||
const statusMessage = document.getElementById(
|
||||
"status-message"
|
||||
);
|
||||
const resultPanel = document.getElementById("result-panel");
|
||||
|
||||
const onlineBadge = document.getElementById("online-badge");
|
||||
const statusCode = document.getElementById("status-code");
|
||||
const responseTime = document.getElementById("response-time");
|
||||
const redirectCount = document.getElementById("redirect-count");
|
||||
const responseTime = document.getElementById(
|
||||
"response-time"
|
||||
);
|
||||
const redirectCount = document.getElementById(
|
||||
"redirect-count"
|
||||
);
|
||||
const checkedAt = document.getElementById("checked-at");
|
||||
const finalUrl = document.getElementById("final-url");
|
||||
|
||||
// Handle Website Status form submissions.
|
||||
uptimeForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const url = urlInput.value.trim();
|
||||
|
||||
// Reset the interface before checking.
|
||||
statusMessage.textContent = "Checking website...";
|
||||
statusMessage.className = "status-message";
|
||||
resultPanel.hidden = true;
|
||||
|
||||
checkButton.disabled = true;
|
||||
checkButton.textContent = "Checking...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/uptime", {
|
||||
method: "POST",
|
||||
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Handle HTTP-level errors.
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || "The request failed.");
|
||||
throw new Error(
|
||||
result.error || "The request failed."
|
||||
);
|
||||
}
|
||||
|
||||
// Handle unreachable or blocked destinations.
|
||||
if (!result.online) {
|
||||
throw new Error(result.error || "The website could not be reached.");
|
||||
throw new Error(
|
||||
result.error ||
|
||||
"The website could not be reached."
|
||||
);
|
||||
}
|
||||
|
||||
// Fill in the successful result.
|
||||
onlineBadge.textContent = "Online";
|
||||
onlineBadge.className = "status-badge online";
|
||||
|
||||
statusCode.textContent = result.statusCode;
|
||||
responseTime.textContent = `${result.responseTimeMs} ms`;
|
||||
responseTime.textContent =
|
||||
`${result.responseTimeMs} ms`;
|
||||
redirectCount.textContent = result.redirectCount;
|
||||
checkedAt.textContent = new Date(result.checkedAt).toLocaleString();
|
||||
|
||||
checkedAt.textContent =
|
||||
new Date(result.checkedAt).toLocaleString();
|
||||
|
||||
finalUrl.textContent = result.finalUrl;
|
||||
finalUrl.href = result.finalUrl;
|
||||
|
||||
statusMessage.textContent = "Website check completed.";
|
||||
statusMessage.textContent =
|
||||
"Website check completed.";
|
||||
|
||||
resultPanel.hidden = false;
|
||||
} catch (error) {
|
||||
statusMessage.textContent = error.message;
|
||||
@@ -97,36 +126,52 @@ uptimeForm.addEventListener("submit", async (event) => {
|
||||
// ---------------------------------------------------------
|
||||
|
||||
const dnsForm = document.getElementById("dns-form");
|
||||
const hostnameInput = document.getElementById("hostname-input");
|
||||
const hostnameInput = document.getElementById(
|
||||
"hostname-input"
|
||||
);
|
||||
const dnsButton = document.getElementById("dns-button");
|
||||
const dnsMessage = document.getElementById("dns-message");
|
||||
const dnsResultPanel = document.getElementById("dns-result-panel");
|
||||
const dnsHostname = document.getElementById("dns-hostname");
|
||||
const dnsResultPanel = document.getElementById(
|
||||
"dns-result-panel"
|
||||
);
|
||||
const dnsHostname = document.getElementById(
|
||||
"dns-hostname"
|
||||
);
|
||||
const dnsRecords = document.getElementById("dns-records");
|
||||
const dnsCheckedAt = document.getElementById("dns-checked-at");
|
||||
const dnsCheckedAt = document.getElementById(
|
||||
"dns-checked-at"
|
||||
);
|
||||
|
||||
// Convert one DNS record into readable text.
|
||||
function formatDnsRecord(recordType, record) {
|
||||
if (recordType === "MX" && typeof record === "object") {
|
||||
// MX records are objects containing an exchange and priority.
|
||||
if (
|
||||
recordType === "MX" &&
|
||||
typeof record === "object" &&
|
||||
record !== null
|
||||
) {
|
||||
return `${record.exchange} (priority ${record.priority})`;
|
||||
}
|
||||
|
||||
return String(record);
|
||||
}
|
||||
|
||||
// Build one visible record group.
|
||||
// Build one DNS record section.
|
||||
function createDnsRecordGroup(recordType, records) {
|
||||
const group = document.createElement("section");
|
||||
group.className = "dns-record-group";
|
||||
|
||||
const heading = document.createElement("h4");
|
||||
heading.textContent = recordType;
|
||||
|
||||
group.appendChild(heading);
|
||||
|
||||
// Show an empty state when no records exist.
|
||||
if (!Array.isArray(records) || records.length === 0) {
|
||||
const emptyMessage = document.createElement("p");
|
||||
emptyMessage.className = "empty-record";
|
||||
emptyMessage.textContent = "No records found.";
|
||||
|
||||
group.appendChild(emptyMessage);
|
||||
|
||||
return group;
|
||||
@@ -137,7 +182,12 @@ function createDnsRecordGroup(recordType, records) {
|
||||
|
||||
records.forEach((record) => {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = formatDnsRecord(recordType, record);
|
||||
|
||||
item.textContent = formatDnsRecord(
|
||||
recordType,
|
||||
record
|
||||
);
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
@@ -146,37 +196,54 @@ function createDnsRecordGroup(recordType, records) {
|
||||
return group;
|
||||
}
|
||||
|
||||
// Handle DNS form submissions.
|
||||
dnsForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const hostname = hostnameInput.value.trim();
|
||||
|
||||
// Reset the DNS interface before the request.
|
||||
dnsMessage.textContent = "Looking up DNS records...";
|
||||
dnsMessage.className = "status-message";
|
||||
dnsResultPanel.hidden = true;
|
||||
|
||||
dnsButton.disabled = true;
|
||||
dnsButton.textContent = "Looking Up...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/dns", {
|
||||
method: "POST",
|
||||
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
body: JSON.stringify({ hostname }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || "The DNS lookup failed.");
|
||||
throw new Error(
|
||||
result.error || "The DNS lookup failed."
|
||||
);
|
||||
}
|
||||
|
||||
dnsHostname.textContent = result.hostname;
|
||||
|
||||
// Remove any records from the previous lookup.
|
||||
dnsRecords.replaceChildren();
|
||||
|
||||
const recordOrder = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"];
|
||||
const recordOrder = [
|
||||
"A",
|
||||
"AAAA",
|
||||
"CNAME",
|
||||
"MX",
|
||||
"TXT",
|
||||
"NS",
|
||||
];
|
||||
|
||||
// Render each supported record type.
|
||||
recordOrder.forEach((recordType) => {
|
||||
const group = createDnsRecordGroup(
|
||||
recordType,
|
||||
@@ -187,7 +254,9 @@ dnsForm.addEventListener("submit", async (event) => {
|
||||
});
|
||||
|
||||
dnsCheckedAt.textContent =
|
||||
`Checked ${new Date(result.checkedAt).toLocaleString()}`;
|
||||
`Checked ${new Date(
|
||||
result.checkedAt
|
||||
).toLocaleString()}`;
|
||||
|
||||
dnsMessage.textContent = "DNS lookup completed.";
|
||||
dnsResultPanel.hidden = false;
|
||||
@@ -199,11 +268,12 @@ dnsForm.addEventListener("submit", async (event) => {
|
||||
dnsButton.textContent = "Look Up DNS";
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// SSL Certificate Checker
|
||||
// ---------------------------------------------------------
|
||||
|
||||
// Get references to the SSL form and result elements.
|
||||
const sslForm = document.getElementById("ssl-form");
|
||||
const sslHostnameInput = document.getElementById(
|
||||
"ssl-hostname-input"
|
||||
@@ -217,16 +287,22 @@ const sslResultPanel = document.getElementById(
|
||||
const sslStatusBadge = document.getElementById(
|
||||
"ssl-status-badge"
|
||||
);
|
||||
const sslHostname = document.getElementById("ssl-hostname");
|
||||
const sslHostname = document.getElementById(
|
||||
"ssl-hostname"
|
||||
);
|
||||
const sslDaysRemaining = document.getElementById(
|
||||
"ssl-days-remaining"
|
||||
);
|
||||
const sslValidFrom = document.getElementById("ssl-valid-from");
|
||||
const sslValidFrom = document.getElementById(
|
||||
"ssl-valid-from"
|
||||
);
|
||||
const sslValidTo = document.getElementById("ssl-valid-to");
|
||||
const sslProtocol = document.getElementById("ssl-protocol");
|
||||
const sslCipher = document.getElementById("ssl-cipher");
|
||||
const sslAddress = document.getElementById("ssl-address");
|
||||
const sslCheckedAt = document.getElementById("ssl-checked-at");
|
||||
const sslCheckedAt = document.getElementById(
|
||||
"ssl-checked-at"
|
||||
);
|
||||
|
||||
const sslSubject = document.getElementById("ssl-subject");
|
||||
const sslIssuer = document.getElementById("ssl-issuer");
|
||||
@@ -234,14 +310,16 @@ const sslFingerprint = document.getElementById(
|
||||
"ssl-fingerprint"
|
||||
);
|
||||
|
||||
const sslAuthorizationErrorBlock = document.getElementById(
|
||||
"ssl-authorization-error-block"
|
||||
);
|
||||
const sslAuthorizationErrorBlock =
|
||||
document.getElementById(
|
||||
"ssl-authorization-error-block"
|
||||
);
|
||||
|
||||
const sslAuthorizationError = document.getElementById(
|
||||
"ssl-authorization-error"
|
||||
);
|
||||
|
||||
// Convert certificate date strings into a readable local date.
|
||||
// Convert a certificate date into local readable text.
|
||||
function formatCertificateDate(value) {
|
||||
if (!value) {
|
||||
return "Unknown";
|
||||
@@ -256,9 +334,12 @@ function formatCertificateDate(value) {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
// Choose readable text for the certificate expiration state.
|
||||
// Convert days remaining into readable text.
|
||||
function getExpirationText(daysRemaining) {
|
||||
if (daysRemaining === null || daysRemaining === undefined) {
|
||||
if (
|
||||
daysRemaining === null ||
|
||||
daysRemaining === undefined
|
||||
) {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
@@ -279,19 +360,22 @@ sslForm.addEventListener("submit", async (event) => {
|
||||
|
||||
const hostname = sslHostnameInput.value.trim();
|
||||
|
||||
// Reset the SSL interface before beginning the request.
|
||||
// Reset the SSL interface before the request.
|
||||
sslMessage.textContent = "Checking certificate...";
|
||||
sslMessage.className = "status-message";
|
||||
sslResultPanel.hidden = true;
|
||||
|
||||
sslButton.disabled = true;
|
||||
sslButton.textContent = "Checking...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ssl", {
|
||||
method: "POST",
|
||||
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
body: JSON.stringify({ hostname }),
|
||||
});
|
||||
|
||||
@@ -299,53 +383,60 @@ sslForm.addEventListener("submit", async (event) => {
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
result.error || "The SSL certificate check failed."
|
||||
result.error ||
|
||||
"The SSL certificate check failed."
|
||||
);
|
||||
}
|
||||
|
||||
// Show whether Node trusted the certificate chain and hostname.
|
||||
// Show whether Node trusted the certificate.
|
||||
if (result.authorized) {
|
||||
sslStatusBadge.textContent = "Valid";
|
||||
sslStatusBadge.className = "status-badge online";
|
||||
sslStatusBadge.className =
|
||||
"status-badge online";
|
||||
} else {
|
||||
sslStatusBadge.textContent = "Not trusted";
|
||||
sslStatusBadge.className = "status-badge offline";
|
||||
sslStatusBadge.className =
|
||||
"status-badge offline";
|
||||
}
|
||||
|
||||
// Populate the certificate summary.
|
||||
sslHostname.textContent = result.hostname;
|
||||
sslDaysRemaining.textContent = getExpirationText(
|
||||
result.daysRemaining
|
||||
);
|
||||
|
||||
sslValidFrom.textContent = formatCertificateDate(
|
||||
result.validFrom
|
||||
);
|
||||
sslValidTo.textContent = formatCertificateDate(
|
||||
result.validTo
|
||||
);
|
||||
sslDaysRemaining.textContent =
|
||||
getExpirationText(result.daysRemaining);
|
||||
|
||||
sslProtocol.textContent = result.protocol || "Unknown";
|
||||
sslCipher.textContent = result.cipher || "Unknown";
|
||||
sslValidFrom.textContent =
|
||||
formatCertificateDate(result.validFrom);
|
||||
|
||||
sslValidTo.textContent =
|
||||
formatCertificateDate(result.validTo);
|
||||
|
||||
sslProtocol.textContent =
|
||||
result.protocol || "Unknown";
|
||||
|
||||
sslCipher.textContent =
|
||||
result.cipher || "Unknown";
|
||||
|
||||
sslAddress.textContent = result.resolvedAddress
|
||||
? `${result.resolvedAddress} (IPv${result.addressFamily})`
|
||||
: "Unknown";
|
||||
|
||||
sslCheckedAt.textContent = formatCertificateDate(
|
||||
result.checkedAt
|
||||
);
|
||||
sslCheckedAt.textContent =
|
||||
formatCertificateDate(result.checkedAt);
|
||||
|
||||
sslSubject.textContent =
|
||||
result.subject || "Unknown";
|
||||
|
||||
sslIssuer.textContent =
|
||||
result.issuer || "Unknown";
|
||||
|
||||
// Populate the longer certificate values.
|
||||
sslSubject.textContent = result.subject || "Unknown";
|
||||
sslIssuer.textContent = result.issuer || "Unknown";
|
||||
sslFingerprint.textContent =
|
||||
result.fingerprint256 || "Unavailable";
|
||||
|
||||
// Show the authorization problem only when one exists.
|
||||
// Only show the authorization error when one exists.
|
||||
if (result.authorizationError) {
|
||||
sslAuthorizationError.textContent =
|
||||
result.authorizationError;
|
||||
|
||||
sslAuthorizationErrorBlock.hidden = false;
|
||||
} else {
|
||||
sslAuthorizationError.textContent = "";
|
||||
@@ -354,6 +445,7 @@ sslForm.addEventListener("submit", async (event) => {
|
||||
|
||||
sslMessage.textContent =
|
||||
"Certificate check completed.";
|
||||
|
||||
sslResultPanel.hidden = false;
|
||||
} catch (error) {
|
||||
sslMessage.textContent = error.message;
|
||||
@@ -362,4 +454,208 @@ sslForm.addEventListener("submit", async (event) => {
|
||||
sslButton.disabled = false;
|
||||
sslButton.textContent = "Check Certificate";
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// HTTP Headers Checker
|
||||
// ---------------------------------------------------------
|
||||
|
||||
const headersForm = document.getElementById(
|
||||
"headers-form"
|
||||
);
|
||||
const headersUrlInput = document.getElementById(
|
||||
"headers-url-input"
|
||||
);
|
||||
const headersButton = document.getElementById(
|
||||
"headers-button"
|
||||
);
|
||||
const headersMessage = document.getElementById(
|
||||
"headers-message"
|
||||
);
|
||||
const headersResultPanel = document.getElementById(
|
||||
"headers-result-panel"
|
||||
);
|
||||
|
||||
const headersScoreBadge = document.getElementById(
|
||||
"headers-score-badge"
|
||||
);
|
||||
const headersStatus = document.getElementById(
|
||||
"headers-status"
|
||||
);
|
||||
const headersRedirects = document.getElementById(
|
||||
"headers-redirects"
|
||||
);
|
||||
const headersScore = document.getElementById(
|
||||
"headers-score"
|
||||
);
|
||||
const headersCheckedAt = document.getElementById(
|
||||
"headers-checked-at"
|
||||
);
|
||||
const headersFinalUrl = document.getElementById(
|
||||
"headers-final-url"
|
||||
);
|
||||
|
||||
const securityHeaderResults = document.getElementById(
|
||||
"security-header-results"
|
||||
);
|
||||
const allHeaderResults = document.getElementById(
|
||||
"all-header-results"
|
||||
);
|
||||
|
||||
// Build one visible security-header result.
|
||||
function createSecurityHeaderItem(header) {
|
||||
const item = document.createElement("article");
|
||||
item.className = "security-header-item";
|
||||
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "security-header-heading";
|
||||
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = header.name;
|
||||
|
||||
const status = document.createElement("span");
|
||||
|
||||
status.className = header.present
|
||||
? "header-status present"
|
||||
: "header-status missing";
|
||||
|
||||
status.textContent = header.present
|
||||
? "Present"
|
||||
: "Missing";
|
||||
|
||||
heading.append(name, status);
|
||||
|
||||
const purpose = document.createElement("p");
|
||||
purpose.className = "security-header-purpose";
|
||||
purpose.textContent = header.purpose;
|
||||
|
||||
item.append(heading, purpose);
|
||||
|
||||
// Show the raw header value when present.
|
||||
if (header.present && header.value) {
|
||||
const value = document.createElement("p");
|
||||
value.className = "security-header-value";
|
||||
|
||||
const code = document.createElement("code");
|
||||
code.textContent = header.value;
|
||||
|
||||
value.appendChild(code);
|
||||
item.appendChild(value);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
// Build one raw response-header row.
|
||||
function createResponseHeaderItem(name, value) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "response-header-item";
|
||||
|
||||
const nameElement = document.createElement("div");
|
||||
nameElement.className = "response-header-name";
|
||||
nameElement.textContent = name;
|
||||
|
||||
const valueElement = document.createElement("div");
|
||||
valueElement.className = "response-header-value";
|
||||
valueElement.textContent = value;
|
||||
|
||||
item.append(nameElement, valueElement);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
// Handle HTTP header form submissions.
|
||||
headersForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const url = headersUrlInput.value.trim();
|
||||
|
||||
// Reset the interface before checking.
|
||||
headersMessage.textContent =
|
||||
"Checking response headers...";
|
||||
|
||||
headersMessage.className = "status-message";
|
||||
headersResultPanel.hidden = true;
|
||||
|
||||
headersButton.disabled = true;
|
||||
headersButton.textContent = "Checking...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/headers", {
|
||||
method: "POST",
|
||||
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
result.error ||
|
||||
"The HTTP header check failed."
|
||||
);
|
||||
}
|
||||
|
||||
// Fill in the summary information.
|
||||
headersStatus.textContent =
|
||||
`${result.statusCode} ${
|
||||
result.statusText || ""
|
||||
}`.trim();
|
||||
|
||||
headersRedirects.textContent =
|
||||
result.redirectCount;
|
||||
|
||||
headersScore.textContent =
|
||||
`${result.presentSecurityHeaderCount} of ` +
|
||||
`${result.totalSecurityHeaderCount}`;
|
||||
|
||||
headersScoreBadge.textContent =
|
||||
`${result.presentSecurityHeaderCount}/` +
|
||||
`${result.totalSecurityHeaderCount}`;
|
||||
|
||||
headersCheckedAt.textContent =
|
||||
new Date(result.checkedAt).toLocaleString();
|
||||
|
||||
headersFinalUrl.textContent = result.finalUrl;
|
||||
headersFinalUrl.href = result.finalUrl;
|
||||
|
||||
// Remove old security header results.
|
||||
securityHeaderResults.replaceChildren();
|
||||
|
||||
result.securityHeaders.forEach((header) => {
|
||||
securityHeaderResults.appendChild(
|
||||
createSecurityHeaderItem(header)
|
||||
);
|
||||
});
|
||||
|
||||
// Remove old raw response headers.
|
||||
allHeaderResults.replaceChildren();
|
||||
|
||||
// Render all response headers alphabetically.
|
||||
Object.entries(result.headers)
|
||||
.sort(([nameA], [nameB]) =>
|
||||
nameA.localeCompare(nameB)
|
||||
)
|
||||
.forEach(([name, value]) => {
|
||||
allHeaderResults.appendChild(
|
||||
createResponseHeaderItem(name, value)
|
||||
);
|
||||
});
|
||||
|
||||
headersMessage.textContent =
|
||||
"Header check completed.";
|
||||
|
||||
headersResultPanel.hidden = false;
|
||||
} catch (error) {
|
||||
headersMessage.textContent = error.message;
|
||||
headersMessage.className = "status-message error";
|
||||
} finally {
|
||||
headersButton.disabled = false;
|
||||
headersButton.textContent = "Check Headers";
|
||||
}
|
||||
});
|
||||
+142
-7
@@ -15,10 +15,12 @@
|
||||
<main class="page-shell">
|
||||
<header class="site-header">
|
||||
<p class="eyebrow">BYTEGEIST TOOLBOX</p>
|
||||
|
||||
<h1>Practical Website and Network Tools</h1>
|
||||
|
||||
<p class="description">
|
||||
Check a website's current status, inspect its public DNS records,
|
||||
or review its SSL certificate.
|
||||
review its SSL certificate, or analyze its HTTP response headers.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -49,12 +51,22 @@
|
||||
>
|
||||
SSL Certificate
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="headers-tab"
|
||||
class="tool-tab"
|
||||
type="button"
|
||||
data-tool="headers-tool"
|
||||
>
|
||||
HTTP Headers
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Website Status Checker -->
|
||||
<section id="status-tool" class="tool-panel">
|
||||
<header class="tool-header">
|
||||
<h2>Website Status Checker</h2>
|
||||
|
||||
<p class="tool-description">
|
||||
Check whether a public website is responding right now and view its
|
||||
status code, response time, redirects, and final destination.
|
||||
@@ -80,12 +92,20 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p id="status-message" class="status-message" aria-live="polite"></p>
|
||||
<p
|
||||
id="status-message"
|
||||
class="status-message"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
|
||||
<section id="result-panel" class="result-panel" hidden>
|
||||
<div class="result-heading">
|
||||
<h3>Current Status</h3>
|
||||
<span id="online-badge" class="status-badge"></span>
|
||||
|
||||
<span
|
||||
id="online-badge"
|
||||
class="status-badge"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<dl class="result-grid">
|
||||
@@ -112,6 +132,7 @@
|
||||
|
||||
<div class="final-url-block">
|
||||
<span>Final URL</span>
|
||||
|
||||
<a
|
||||
id="final-url"
|
||||
href="#"
|
||||
@@ -126,6 +147,7 @@
|
||||
<section id="dns-tool" class="tool-panel" hidden>
|
||||
<header class="tool-header">
|
||||
<h2>DNS Lookup</h2>
|
||||
|
||||
<p class="tool-description">
|
||||
Look up public A, AAAA, CNAME, MX, TXT, and NS records for a domain.
|
||||
</p>
|
||||
@@ -150,17 +172,31 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p id="dns-message" class="status-message" aria-live="polite"></p>
|
||||
<p
|
||||
id="dns-message"
|
||||
class="status-message"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
|
||||
<section id="dns-result-panel" class="result-panel" hidden>
|
||||
<div class="result-heading">
|
||||
<h3>DNS Records</h3>
|
||||
<span id="dns-hostname" class="hostname-badge"></span>
|
||||
|
||||
<span
|
||||
id="dns-hostname"
|
||||
class="hostname-badge"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div id="dns-records" class="dns-records"></div>
|
||||
<div
|
||||
id="dns-records"
|
||||
class="dns-records"
|
||||
></div>
|
||||
|
||||
<p id="dns-checked-at" class="dns-checked-at"></p>
|
||||
<p
|
||||
id="dns-checked-at"
|
||||
class="dns-checked-at"
|
||||
></p>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -277,6 +313,105 @@
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- HTTP Headers Checker -->
|
||||
<section id="headers-tool" class="tool-panel" hidden>
|
||||
<header class="tool-header">
|
||||
<h2>HTTP Headers Checker</h2>
|
||||
|
||||
<p class="tool-description">
|
||||
Inspect a website's response headers and see which common security
|
||||
headers are present or missing.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form id="headers-form" class="tool-form">
|
||||
<label for="headers-url-input">Website URL</label>
|
||||
|
||||
<div class="input-row">
|
||||
<input
|
||||
id="headers-url-input"
|
||||
name="url"
|
||||
type="url"
|
||||
placeholder="https://example.com"
|
||||
autocomplete="url"
|
||||
required
|
||||
>
|
||||
|
||||
<button id="headers-button" type="submit">
|
||||
Check Headers
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p
|
||||
id="headers-message"
|
||||
class="status-message"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
|
||||
<section id="headers-result-panel" class="result-panel" hidden>
|
||||
<div class="result-heading">
|
||||
<h3>Header Results</h3>
|
||||
|
||||
<span
|
||||
id="headers-score-badge"
|
||||
class="hostname-badge"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<dl class="result-grid">
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd id="headers-status">-</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt>Redirects</dt>
|
||||
<dd id="headers-redirects">-</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt>Security headers</dt>
|
||||
<dd id="headers-score">-</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt>Checked at</dt>
|
||||
<dd id="headers-checked-at">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="final-url-block">
|
||||
<span>Final URL</span>
|
||||
|
||||
<a
|
||||
id="headers-final-url"
|
||||
href="#"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
></a>
|
||||
</div>
|
||||
|
||||
<section class="headers-section">
|
||||
<h4>Security Header Review</h4>
|
||||
|
||||
<div
|
||||
id="security-header-results"
|
||||
class="security-header-results"
|
||||
></div>
|
||||
</section>
|
||||
|
||||
<section class="headers-section">
|
||||
<h4>All Response Headers</h4>
|
||||
|
||||
<div
|
||||
id="all-header-results"
|
||||
class="all-header-results"
|
||||
></div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Load browser JavaScript after the page elements exist. -->
|
||||
|
||||
+297
-111
@@ -18,12 +18,24 @@
|
||||
--danger: #ff6b73;
|
||||
}
|
||||
|
||||
/* Always respect the HTML hidden attribute. */
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
|
||||
background:
|
||||
radial-gradient(circle at top, #17253b 0%, var(--background) 42%);
|
||||
radial-gradient(
|
||||
circle at top,
|
||||
#17253b 0%,
|
||||
var(--background) 42%
|
||||
);
|
||||
|
||||
color: var(--text);
|
||||
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
@@ -39,24 +51,29 @@ input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Main page layout. */
|
||||
.page-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
min-height: 100vh;
|
||||
padding: 32px 20px;
|
||||
}
|
||||
|
||||
.tool-panel {
|
||||
/* Main site heading area. */
|
||||
.site-header {
|
||||
width: min(760px, 100%);
|
||||
padding: 32px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: rgba(17, 24, 39, 0.96);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
|
||||
color: var(--accent);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
@@ -65,26 +82,91 @@ input {
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 12px;
|
||||
|
||||
font-size: clamp(2rem, 5vw, 3.25rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 62ch;
|
||||
margin-bottom: 28px;
|
||||
margin-bottom: 0;
|
||||
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.uptime-form label {
|
||||
/* Tool navigation tabs. */
|
||||
.tool-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
|
||||
width: min(760px, 100%);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.tool-tab {
|
||||
padding: 10px 14px;
|
||||
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
|
||||
background: #0d1523;
|
||||
color: var(--muted);
|
||||
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tool-tab:hover {
|
||||
background: var(--surface-light);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tool-tab.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(76, 166, 255, 0.12);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Shared tool panel layout. */
|
||||
.tool-panel {
|
||||
width: min(760px, 100%);
|
||||
padding: 32px;
|
||||
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
|
||||
background: rgba(17, 24, 39, 0.96);
|
||||
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.tool-header h2 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.tool-description {
|
||||
margin-bottom: 24px;
|
||||
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Shared form styles. */
|
||||
.tool-form label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
@@ -97,29 +179,36 @@ h1 {
|
||||
input {
|
||||
min-width: 0;
|
||||
padding: 13px 14px;
|
||||
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
|
||||
background: #0b1220;
|
||||
color: var(--text);
|
||||
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(76, 166, 255, 0.16);
|
||||
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(76, 166, 255, 0.16);
|
||||
}
|
||||
|
||||
button {
|
||||
button[type="submit"] {
|
||||
padding: 13px 18px;
|
||||
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
|
||||
background: var(--accent);
|
||||
color: #04101c;
|
||||
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
button[type="submit"]:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
@@ -128,9 +217,11 @@ button:disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/* Loading, success, and error message area. */
|
||||
.status-message {
|
||||
min-height: 24px;
|
||||
margin: 18px 0 0;
|
||||
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@@ -138,11 +229,14 @@ button:disabled {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Shared result panel. */
|
||||
.result-panel {
|
||||
margin-top: 24px;
|
||||
padding: 22px;
|
||||
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
|
||||
background: var(--surface-light);
|
||||
}
|
||||
|
||||
@@ -151,17 +245,22 @@ button:disabled {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.result-heading h2 {
|
||||
.result-heading h3 {
|
||||
margin-bottom: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
/* Shared status badges. */
|
||||
.status-badge,
|
||||
.hostname-badge {
|
||||
padding: 5px 10px;
|
||||
|
||||
border-radius: 999px;
|
||||
|
||||
font-size: 0.82rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
@@ -176,39 +275,58 @@ button:disabled {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.hostname-badge {
|
||||
background: rgba(76, 166, 255, 0.12);
|
||||
color: var(--accent);
|
||||
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Shared result grid. */
|
||||
.result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.result-grid div {
|
||||
padding: 14px;
|
||||
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
|
||||
background: #0d1523;
|
||||
}
|
||||
|
||||
.result-grid dt,
|
||||
.final-url-block span {
|
||||
.final-url-block span,
|
||||
.certificate-detail-block span {
|
||||
margin-bottom: 6px;
|
||||
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.result-grid dd {
|
||||
margin: 0;
|
||||
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Final URL display. */
|
||||
.final-url-block {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
|
||||
background: #0d1523;
|
||||
}
|
||||
|
||||
@@ -217,78 +335,7 @@ button:disabled {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tool-panel {
|
||||
padding: 24px 18px;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.result-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.site-header {
|
||||
width: min(760px, 100%);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.site-header h1 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tool-tabs {
|
||||
display: flex;
|
||||
width: min(760px, 100%);
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.tool-tab {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0d1523;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tool-tab:hover {
|
||||
background: var(--surface-light);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tool-tab.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(76, 166, 255, 0.12);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tool-header h2 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.tool-description {
|
||||
margin-bottom: 24px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hostname-badge {
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(76, 166, 255, 0.12);
|
||||
color: var(--accent);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* DNS result styles. */
|
||||
.dns-records {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -297,6 +344,7 @@ button:disabled {
|
||||
.dns-record-group {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
|
||||
background: #0d1523;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -304,7 +352,9 @@ button:disabled {
|
||||
.dns-record-group h4 {
|
||||
margin: 0;
|
||||
padding: 11px 14px;
|
||||
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.08em;
|
||||
@@ -323,39 +373,29 @@ button:disabled {
|
||||
.empty-record {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dns-checked-at {
|
||||
margin: 14px 0 0;
|
||||
|
||||
color: var(--muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tool-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.tool-tab {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* SSL certificate result styles. */
|
||||
.certificate-detail-block {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #0d1523;
|
||||
}
|
||||
|
||||
.certificate-detail-block span {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
background: #0d1523;
|
||||
}
|
||||
|
||||
.certificate-detail-block p {
|
||||
@@ -365,10 +405,12 @@ button:disabled {
|
||||
|
||||
.certificate-detail-block code {
|
||||
color: var(--accent);
|
||||
|
||||
font-family:
|
||||
"Cascadia Code",
|
||||
"Courier New",
|
||||
monospace;
|
||||
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
@@ -378,13 +420,157 @@ button:disabled {
|
||||
border-color: rgba(255, 107, 115, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* HTTP header result sections. */
|
||||
.headers-section {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.headers-section h4 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.security-header-results,
|
||||
.all-header-results {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* One security header result card. */
|
||||
.security-header-item {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
|
||||
padding: 14px;
|
||||
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
|
||||
background: #0d1523;
|
||||
}
|
||||
|
||||
.security-header-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.security-header-heading strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.security-header-purpose,
|
||||
.security-header-value {
|
||||
margin: 0;
|
||||
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.security-header-value code {
|
||||
color: var(--accent);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Present or missing security header badge. */
|
||||
.header-status {
|
||||
flex-shrink: 0;
|
||||
|
||||
padding: 4px 8px;
|
||||
|
||||
border-radius: 999px;
|
||||
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.header-status.present {
|
||||
background: rgba(73, 209, 125, 0.14);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.header-status.missing {
|
||||
background: rgba(255, 107, 115, 0.14);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* One raw response header row. */
|
||||
.response-header-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 220px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
|
||||
padding: 12px 14px;
|
||||
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
|
||||
background: #0d1523;
|
||||
}
|
||||
|
||||
.response-header-name {
|
||||
color: var(--accent);
|
||||
|
||||
font-family:
|
||||
"Cascadia Code",
|
||||
"Courier New",
|
||||
monospace;
|
||||
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.response-header-value {
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Tablet layout. */
|
||||
@media (max-width: 780px) {
|
||||
.tool-tabs {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* Always hide elements that use the HTML hidden attribute. */
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
/* Phone layout. */
|
||||
@media (max-width: 640px) {
|
||||
.page-shell {
|
||||
padding: 24px 14px;
|
||||
}
|
||||
|
||||
.tool-panel {
|
||||
padding: 24px 18px;
|
||||
}
|
||||
|
||||
.tool-tabs {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tool-tab {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.result-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.response-header-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Import Express so we can create the headers route.
|
||||
const express = require("express");
|
||||
|
||||
// Import the headers inspection service.
|
||||
const {
|
||||
checkHeaders,
|
||||
} = require("../services/headersService");
|
||||
|
||||
// Reuse the existing rate limiter.
|
||||
const {
|
||||
uptimeRateLimiter,
|
||||
} = require("../middleware/rateLimit");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// POST /api/headers
|
||||
router.post("/", uptimeRateLimiter, async (req, res) => {
|
||||
const { url } = req.body;
|
||||
|
||||
// Confirm that a URL was submitted.
|
||||
if (typeof url !== "string" || url.trim() === "") {
|
||||
return res.status(400).json({
|
||||
error: "A URL is required.",
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent excessively large input.
|
||||
if (url.length > 2048) {
|
||||
return res.status(400).json({
|
||||
error: "The URL is too long.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await checkHeaders(url.trim());
|
||||
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
error.message ||
|
||||
"The website headers could not be checked.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -20,6 +20,9 @@ const PORT = process.env.PORT || 3001;
|
||||
// Import the SSL certificate API route.
|
||||
const sslRouter = require("./routes/ssl");
|
||||
|
||||
// Import the HTTP headers API route.
|
||||
const headersRouter = require("./routes/headers");
|
||||
|
||||
// Allow the server to read JSON request bodies.
|
||||
app.use(express.json());
|
||||
|
||||
@@ -35,6 +38,9 @@ app.use("/api/dns", dnsRouter);
|
||||
// Handle SSL certificate checks.
|
||||
app.use("/api/ssl", sslRouter);
|
||||
|
||||
// Handle HTTP and security header checks.
|
||||
app.use("/api/headers", headersRouter);
|
||||
|
||||
// Simple health-check route.
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// Reuse the public URL validator so this tool cannot access local systems.
|
||||
const {
|
||||
validatePublicUrl,
|
||||
} = require("./urlSecurityService");
|
||||
|
||||
// Maximum redirects allowed during one check.
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
// Maximum time allowed for each request.
|
||||
const REQUEST_TIMEOUT_MS = 5000;
|
||||
|
||||
// Security headers commonly used by public websites.
|
||||
const SECURITY_HEADERS = [
|
||||
{
|
||||
name: "strict-transport-security",
|
||||
label: "Strict-Transport-Security",
|
||||
purpose: "Forces browsers to use HTTPS for future connections.",
|
||||
},
|
||||
{
|
||||
name: "content-security-policy",
|
||||
label: "Content-Security-Policy",
|
||||
purpose: "Restricts which scripts, styles, and other resources may load.",
|
||||
},
|
||||
{
|
||||
name: "x-content-type-options",
|
||||
label: "X-Content-Type-Options",
|
||||
purpose: "Prevents browsers from guessing content types.",
|
||||
},
|
||||
{
|
||||
name: "x-frame-options",
|
||||
label: "X-Frame-Options",
|
||||
purpose: "Helps prevent clickjacking by controlling iframe use.",
|
||||
},
|
||||
{
|
||||
name: "referrer-policy",
|
||||
label: "Referrer-Policy",
|
||||
purpose: "Controls how much referral information browsers send.",
|
||||
},
|
||||
{
|
||||
name: "permissions-policy",
|
||||
label: "Permissions-Policy",
|
||||
purpose: "Controls access to browser features such as camera and location.",
|
||||
},
|
||||
{
|
||||
name: "cross-origin-opener-policy",
|
||||
label: "Cross-Origin-Opener-Policy",
|
||||
purpose: "Separates browsing contexts from untrusted origins.",
|
||||
},
|
||||
{
|
||||
name: "cross-origin-resource-policy",
|
||||
label: "Cross-Origin-Resource-Policy",
|
||||
purpose: "Controls which origins may load a resource.",
|
||||
},
|
||||
];
|
||||
|
||||
// Check whether a status code represents a redirect.
|
||||
function isRedirectStatus(statusCode) {
|
||||
return [301, 302, 303, 307, 308].includes(statusCode);
|
||||
}
|
||||
|
||||
// Convert the Fetch Headers object into a normal JavaScript object.
|
||||
function headersToObject(headers) {
|
||||
const result = {};
|
||||
|
||||
headers.forEach((value, name) => {
|
||||
result[name] = value;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Review the common security headers returned by a website.
|
||||
function analyzeSecurityHeaders(headers) {
|
||||
return SECURITY_HEADERS.map((header) => {
|
||||
const value = headers[header.name] || null;
|
||||
|
||||
return {
|
||||
name: header.label,
|
||||
present: value !== null,
|
||||
value,
|
||||
purpose: header.purpose,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Inspect the response headers for a public website.
|
||||
async function checkHeaders(submittedUrl) {
|
||||
let currentUrl = submittedUrl;
|
||||
let redirectCount = 0;
|
||||
|
||||
while (redirectCount <= MAX_REDIRECTS) {
|
||||
// Validate every destination before contacting it.
|
||||
const validatedUrl = await validatePublicUrl(currentUrl);
|
||||
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await fetch(validatedUrl, {
|
||||
method: "GET",
|
||||
|
||||
// Redirects are handled manually so each destination can be validated.
|
||||
redirect: "manual",
|
||||
|
||||
// Stop waiting after five seconds.
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
|
||||
headers: {
|
||||
"User-Agent": "ByteGeist-Toolbox/1.0",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error.name === "TimeoutError" ||
|
||||
error.name === "AbortError"
|
||||
) {
|
||||
throw new Error("The website took too long to respond.");
|
||||
}
|
||||
|
||||
throw new Error("The website could not be reached.");
|
||||
}
|
||||
|
||||
// Follow redirects safely.
|
||||
if (isRedirectStatus(response.status)) {
|
||||
const location = response.headers.get("location");
|
||||
|
||||
if (response.body) {
|
||||
await response.body.cancel();
|
||||
}
|
||||
|
||||
if (!location) {
|
||||
throw new Error("The website returned an invalid redirect.");
|
||||
}
|
||||
|
||||
redirectCount += 1;
|
||||
|
||||
if (redirectCount > MAX_REDIRECTS) {
|
||||
throw new Error("The website redirected too many times.");
|
||||
}
|
||||
|
||||
currentUrl = new URL(location, validatedUrl).toString();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect the response headers.
|
||||
const headers = headersToObject(response.headers);
|
||||
|
||||
// Cancel the body because this tool only needs the headers.
|
||||
if (response.body) {
|
||||
await response.body.cancel();
|
||||
}
|
||||
|
||||
const securityHeaders = analyzeSecurityHeaders(headers);
|
||||
|
||||
return {
|
||||
requestedUrl: submittedUrl,
|
||||
finalUrl: validatedUrl.toString(),
|
||||
statusCode: response.status,
|
||||
statusText: response.statusText,
|
||||
redirectCount,
|
||||
headers,
|
||||
securityHeaders,
|
||||
presentSecurityHeaderCount: securityHeaders.filter(
|
||||
(header) => header.present
|
||||
).length,
|
||||
totalSecurityHeaderCount: securityHeaders.length,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("The website redirected too many times.");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkHeaders,
|
||||
headersToObject,
|
||||
analyzeSecurityHeaders,
|
||||
isRedirectStatus,
|
||||
SECURITY_HEADERS,
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
// Import Node's built-in test tools.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
// Import the HTTP headers service functions.
|
||||
const {
|
||||
checkHeaders,
|
||||
headersToObject,
|
||||
analyzeSecurityHeaders,
|
||||
isRedirectStatus,
|
||||
SECURITY_HEADERS,
|
||||
} = require("../src/services/headersService");
|
||||
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// isRedirectStatus() tests
|
||||
// ---------------------------------------------------------
|
||||
|
||||
test("recognizes standard redirect status codes", () => {
|
||||
[301, 302, 303, 307, 308].forEach((statusCode) => {
|
||||
assert.equal(isRedirectStatus(statusCode), true);
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects non-redirect status codes", () => {
|
||||
[200, 201, 204, 400, 404, 500].forEach((statusCode) => {
|
||||
assert.equal(isRedirectStatus(statusCode), false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// headersToObject() tests
|
||||
// ---------------------------------------------------------
|
||||
|
||||
test("converts Fetch Headers into a plain object", () => {
|
||||
const headers = new Headers({
|
||||
"content-type": "text/html",
|
||||
"x-content-type-options": "nosniff",
|
||||
});
|
||||
|
||||
const result = headersToObject(headers);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
"content-type": "text/html",
|
||||
"x-content-type-options": "nosniff",
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes header names to lowercase", () => {
|
||||
const headers = new Headers({
|
||||
"X-Frame-Options": "DENY",
|
||||
});
|
||||
|
||||
const result = headersToObject(headers);
|
||||
|
||||
assert.equal(result["x-frame-options"], "DENY");
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// analyzeSecurityHeaders() tests
|
||||
// ---------------------------------------------------------
|
||||
|
||||
test("marks present security headers correctly", () => {
|
||||
const headers = {
|
||||
"strict-transport-security": "max-age=31536000",
|
||||
"x-content-type-options": "nosniff",
|
||||
};
|
||||
|
||||
const result = analyzeSecurityHeaders(headers);
|
||||
|
||||
const hsts = result.find(
|
||||
(header) => header.name === "Strict-Transport-Security"
|
||||
);
|
||||
|
||||
const contentType = result.find(
|
||||
(header) => header.name === "X-Content-Type-Options"
|
||||
);
|
||||
|
||||
assert.equal(hsts.present, true);
|
||||
assert.equal(
|
||||
hsts.value,
|
||||
"max-age=31536000"
|
||||
);
|
||||
|
||||
assert.equal(contentType.present, true);
|
||||
assert.equal(contentType.value, "nosniff");
|
||||
});
|
||||
|
||||
test("marks missing security headers correctly", () => {
|
||||
const result = analyzeSecurityHeaders({});
|
||||
|
||||
result.forEach((header) => {
|
||||
assert.equal(header.present, false);
|
||||
assert.equal(header.value, null);
|
||||
});
|
||||
});
|
||||
|
||||
test("returns one result for every configured security header", () => {
|
||||
const result = analyzeSecurityHeaders({});
|
||||
|
||||
assert.equal(
|
||||
result.length,
|
||||
SECURITY_HEADERS.length
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the expected security header labels", () => {
|
||||
const result = analyzeSecurityHeaders({});
|
||||
|
||||
const names = result.map((header) => header.name);
|
||||
|
||||
assert.deepEqual(names, [
|
||||
"Strict-Transport-Security",
|
||||
"Content-Security-Policy",
|
||||
"X-Content-Type-Options",
|
||||
"X-Frame-Options",
|
||||
"Referrer-Policy",
|
||||
"Permissions-Policy",
|
||||
"Cross-Origin-Opener-Policy",
|
||||
"Cross-Origin-Resource-Policy",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// checkHeaders() integration tests
|
||||
// ---------------------------------------------------------
|
||||
|
||||
test("checks headers for a public website", async () => {
|
||||
const result = await checkHeaders(
|
||||
"https://example.com"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result.requestedUrl,
|
||||
"https://example.com"
|
||||
);
|
||||
|
||||
assert.equal(typeof result.finalUrl, "string");
|
||||
assert.equal(typeof result.statusCode, "number");
|
||||
assert.equal(typeof result.statusText, "string");
|
||||
assert.equal(typeof result.redirectCount, "number");
|
||||
assert.equal(typeof result.headers, "object");
|
||||
|
||||
assert.equal(
|
||||
Array.isArray(result.securityHeaders),
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result.securityHeaders.length,
|
||||
SECURITY_HEADERS.length
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
typeof result.presentSecurityHeaderCount,
|
||||
"number"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result.totalSecurityHeaderCount,
|
||||
SECURITY_HEADERS.length
|
||||
);
|
||||
|
||||
assert.equal(typeof result.checkedAt, "string");
|
||||
});
|
||||
|
||||
test("follows redirects safely", async () => {
|
||||
const result = await checkHeaders(
|
||||
"http://example.com"
|
||||
);
|
||||
|
||||
assert.equal(typeof result.finalUrl, "string");
|
||||
assert.equal(result.redirectCount >= 0, true);
|
||||
});
|
||||
|
||||
test("blocks loopback addresses", async () => {
|
||||
await assert.rejects(
|
||||
checkHeaders("http://127.0.0.1"),
|
||||
/Local and private network addresses are not allowed/
|
||||
);
|
||||
});
|
||||
|
||||
test("blocks private network addresses", async () => {
|
||||
await assert.rejects(
|
||||
checkHeaders("http://192.168.1.1"),
|
||||
/Local and private network addresses are not allowed/
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects malformed URLs", async () => {
|
||||
await assert.rejects(
|
||||
checkHeaders("not a valid URL"),
|
||||
/Enter a valid URL/
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user