diff --git a/public/app.js b/public/app.js index 7d63562..d6d895e 100644 --- a/public/app.js +++ b/public/app.js @@ -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"; + } }); \ No newline at end of file diff --git a/public/index.html b/public/index.html index d07f079..50bc54e 100644 --- a/public/index.html +++ b/public/index.html @@ -15,10 +15,12 @@
@@ -49,12 +51,22 @@ > SSL Certificate + +

Website Status Checker

+

Check whether a public website is responding right now and view its status code, response time, redirects, and final destination. @@ -80,12 +92,20 @@ -

+

diff --git a/public/styles.css b/public/styles.css index 119bfe7..607486f 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; + } } \ No newline at end of file diff --git a/src/routes/headers.js b/src/routes/headers.js new file mode 100644 index 0000000..d5ae07e --- /dev/null +++ b/src/routes/headers.js @@ -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; \ No newline at end of file diff --git a/src/server.js b/src/server.js index 8e813b5..bea614c 100644 --- a/src/server.js +++ b/src/server.js @@ -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({ diff --git a/src/services/headersService.js b/src/services/headersService.js new file mode 100644 index 0000000..eed7b39 --- /dev/null +++ b/src/services/headersService.js @@ -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, +}; \ No newline at end of file diff --git a/tests/headersService.test.js b/tests/headersService.test.js new file mode 100644 index 0000000..265f60d --- /dev/null +++ b/tests/headersService.test.js @@ -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/ + ); +}); \ No newline at end of file