// /api/check — passive, single-request fingerprint of a public site's PHP version. // // This makes ONE ordinary GET request, like a browser visit, and reads the response // headers and the first part of the body. It does not send exploit payloads, does not // enumerate paths, and cannot decide "safe" — only "evidence of a vulnerable version" // or "the server does not advertise its version". // // Deployed as a Cloudflare Pages Function: functions/api/check.js const FIXED = { "8.2": "8.2.35", "8.3": "8.3.36", "8.4": "8.4.27", "8.5": "8.5.12" }; const UA = "PHPHackFix-Checker/1.0 (+https://phphackfix.com/about; passive header check)"; const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", }, }); function isPrivateHost(host) { if (host === "localhost" || host.endsWith(".localhost")) return true; if (host === "metadata.google.internal" || host.endsWith(".internal")) return true; if (host.endsWith(".local") || host.endsWith(".home.arpa") || host.endsWith(".lan")) return true; if (host.startsWith("[") || host.includes(":")) return true; // IPv6 literal — refuse const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (m) { const [a, b] = [Number(m[1]), Number(m[2])]; if (a === 10 || a === 127 || a === 0) return true; if (a === 169 && b === 254) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 192 && b === 168) return true; if (a === 100 && b >= 64 && b <= 127) return true; if (a >= 224) return true; } return false; } function normalise(raw) { let s = String(raw || "").trim(); if (!s) throw new Error("Enter a domain, for example example.com"); if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = "https://" + s; let u; try { u = new URL(s); } catch { throw new Error("That does not look like a domain or URL"); } if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error("Only http and https are supported"); if (u.port && u.port !== "80" && u.port !== "443") throw new Error("Only standard ports (80/443) are checked"); if (isPrivateHost(u.hostname)) throw new Error("Private, loopback and internal addresses are not checked"); if (!u.hostname.includes(".")) throw new Error("That does not look like a public domain"); u.hash = ""; return u; } function compare(a, b) { const A = a.split(".").map(Number), B = b.split(".").map(Number); for (let i = 0; i < 3; i++) { if ((A[i] || 0) !== (B[i] || 0)) return (A[i] || 0) > (B[i] || 0) ? 1 : -1; } return 0; } async function readCapped(res, limit = 120000) { if (!res.body) return ""; const reader = res.body.getReader(); const chunks = []; let size = 0; try { while (size < limit) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); size += value.length; } } catch { /* truncated reads are fine */ } try { await reader.cancel(); } catch {} const buf = new Uint8Array(size); let off = 0; for (const c of chunks) { buf.set(c, off); off += c.length; } return new TextDecoder("utf-8", { fatal: false }).decode(buf.slice(0, limit)); } export async function onRequestPost({ request }) { let payload; try { payload = await request.json(); } catch { return json({ ok: false, error: "Expected JSON body" }, 400); } let target; try { target = normalise(payload.url); } catch (e) { return json({ ok: false, error: e.message }, 400); } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 8000); let res, body = ""; const started = Date.now(); try { res = await fetch(target.toString(), { method: "GET", redirect: "follow", signal: controller.signal, headers: { "user-agent": UA, accept: "text/html,application/xhtml+xml" }, }); const ct = (res.headers.get("content-type") || "").toLowerCase(); if (ct.includes("text/html") || ct.includes("text/plain") || ct === "") { body = await readCapped(res); } } catch (e) { clearTimeout(timer); const aborted = String(e && e.name) === "AbortError"; return json({ ok: true, url: target.toString(), reachable: false, verdict: "unknown", reason: aborted ? "The site did not answer within 8 seconds." : "Could not connect to that site.", detail: String(e && e.message || e).slice(0, 200), }); } clearTimeout(timer); const h = res.headers; const evidence = []; let version = null, source = null; const powered = h.get("x-powered-by") || ""; const server = h.get("server") || ""; if (powered) evidence.push(`X-Powered-By: ${powered}`); let m = powered.match(/PHP\/?[\s]*([0-9]+\.[0-9]+\.[0-9]+)/i); if (m) { version = m[1]; source = "X-Powered-By header"; } if (!version) { // Body-derived versions are only trustworthy on a real phpinfo() page, identified by // its title — not by the mere presence of the words "PHP Version", which appear in // release notes, docs and this site's own examples. That false positive is the whole // risk of a passive scanner, so the test is deliberately strict. const pageTitle = (body.match(/]*>([\s\S]{0,140}?)<\/title>/i) || [])[1] || ""; const phpinfoPage = /phpinfo/i.test(pageTitle) && /PHP\s*Version/i.test(body); if (phpinfoPage) { m = body.match(/PHP\s*Version\s*[:<>=]*\s*([0-9]+\.[0-9]+\.[0-9]+)/i); if (m) { version = m[1]; source = "phpinfo() page"; evidence.push("A phpinfo() page is publicly readable (it leaks more than the version — remove it)"); } } } const phpErrors = /(PHP (Warning|Fatal error|Parse error|Notice)\s*:)/i.test(body); const phpSession = [...h.entries()].some(([k, v]) => k.toLowerCase() === "set-cookie" && /PHPSESSID/i.test(v)); const generator = (body.match(/]+name=["']generator["'][^>]+content=["']([^"']{0,60})/i) || [])[1] || null; if (phpErrors) evidence.push("PHP error text is being displayed to visitors (display_errors is on)"); if (phpSession) evidence.push("PHPSESSID cookie set — the site runs PHP sessions"); if (generator) evidence.push(`Generator: ${generator}`); let verdict = "unknown", fixed = null, headline, notes = []; if (version) { const mm = version.split(".").slice(0, 2).join("."); fixed = FIXED[mm] || null; if (!fixed) { verdict = "vulnerable"; headline = `PHP ${version} has no upstream fix for this vulnerability`; notes.push("PHP 8.1 and older received no official release. A vendor backport or an upgrade is required."); } else if (compare(version, fixed) < 0) { verdict = "vulnerable"; headline = `Running PHP ${version} — upgrade to ${fixed} or later`; notes.push("This version is below the fixed release for its branch, so it is exploitable if it serves web requests."); } else { verdict = "fixed"; headline = `Running PHP ${version} — at or above the fixed ${fixed}`; notes.push("The version is patched, but only if every worker was restarted after the update."); } } else { verdict = "unknown"; headline = "The server does not advertise its PHP version"; notes.push( "This is the normal case: most hosts strip the X-Powered-By header. It is not a pass.", "Definitive check: paste the output of php -v or your host's PHP version into the version checker above.", "If you are the site owner, create a temporary file containing