Add HTTP headers checker and tests

This commit is contained in:
Keith Casko
2026-07-23 05:53:14 -04:00
parent 820f271195
commit 7b77c2261a
7 changed files with 1219 additions and 172 deletions
+350 -54
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+47
View File
@@ -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;
+6
View File
@@ -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({
+179
View File
@@ -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,
};
+198
View File
@@ -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/
);
});