php2shell-scanner.git

php2shell-scanner

~ Check PHP2Shell (CVE-2026-109618, CVE-2026-109619) from inside your server: distribution backports, missed restarts and interim mitigations, verdicts computed in the browser.
git clone https://github.com/debuman/php2shell-scanner.git

tools/serve.mjs (4614B)

      1 #!/usr/bin/env node
      2 // Serve the assembled site in _site/ at http://127.0.0.1:8790 for a local
      3 // preview. No dependencies. Run `node tools/build-site.mjs` first, or use
      4 // `npm run serve`, which does both. Set PORT to use another port.
      5 //
      6 // It answers GET and HEAD only, serves index.html for a directory, and
      7 // redirects a directory without a trailing slash, so relative links on /kr/
      8 // work as on GitHub Pages. A path is served only if its real path, with
      9 // symlinks resolved, is that same file inside _site/, so nothing outside
     10 // _site/ can be read. Where the file system reports the true case (Windows),
     11 // a wrong-case link fails here as it would on GitHub Pages. Downloads (.php,
     12 // .sh) are sent as plain text.
     13 
     14 import { createServer } from 'node:http';
     15 import { createReadStream } from 'node:fs';
     16 import { realpath, stat } from 'node:fs/promises';
     17 import { extname, join, relative } from 'node:path';
     18 import { fileURLToPath } from 'node:url';
     19 
     20 const HOST = '127.0.0.1';
     21 const PORT = Number(process.env.PORT) || 8790;
     22 const ROOT = fileURLToPath(new URL('../_site/', import.meta.url));
     23 
     24 const TYPES = {
     25   '.html': 'text/html; charset=utf-8',
     26   '.js': 'text/javascript; charset=utf-8',
     27   '.mjs': 'text/javascript; charset=utf-8',
     28   '.css': 'text/css; charset=utf-8',
     29   '.json': 'application/json; charset=utf-8',
     30   '.svg': 'image/svg+xml',
     31   '.woff2': 'font/woff2',
     32   '.php': 'text/plain; charset=utf-8',
     33   '.sh': 'text/plain; charset=utf-8',
     34   '.txt': 'text/plain; charset=utf-8',
     35   '': 'text/plain; charset=utf-8', // SHA256SUMS
     36 };
     37 
     38 // The request target as a URL. A path such as //kr stays a path; it is not read as a host.
     39 function parseTarget(target) {
     40   try {
     41     return target.startsWith('/') ? new URL(`http://${HOST}${target}`) : new URL(target);
     42   } catch {
     43     return null;
     44   }
     45 }
     46 
     47 // The decoded path segments, or null if one could step outside _site/ or is
     48 // not a plain file name (separators, a drive or stream colon, NUL).
     49 function segmentsOf(pathname) {
     50   const out = [];
     51   for (const raw of pathname.split('/')) {
     52     if (raw === '') continue;
     53     let s;
     54     try {
     55       s = decodeURIComponent(raw);
     56     } catch {
     57       return null;
     58     }
     59     if (s === '.' || s === '..' || /[\\/:\0]/.test(s)) return null;
     60     out.push(s);
     61   }
     62   return out;
     63 }
     64 
     65 // The real path of file relative to the real _site/ (symlinks resolved, true case).
     66 async function realRelative(file) {
     67   const [root, real] = await Promise.all([realpath(ROOT), realpath(file)]);
     68   return relative(root, real);
     69 }
     70 
     71 function send(res, status, body, headers = {}) {
     72   res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', ...headers });
     73   res.end(res.req.method === 'HEAD' ? undefined : body + '\n');
     74 }
     75 
     76 async function handle(req, res) {
     77   if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' });
     78 
     79   const url = parseTarget(req.url || '/');
     80   const segments = url && segmentsOf(url.pathname);
     81   if (!segments) return send(res, 400, 'Bad request');
     82 
     83   let info = await stat(join(ROOT, ...segments)).catch(() => null);
     84   if (info?.isDirectory()) {
     85     if (!url.pathname.endsWith('/')) {
     86       const location = ['', ...segments.map(encodeURIComponent), ''].join('/') + url.search;
     87       return send(res, 301, 'Moved', { location });
     88     }
     89     segments.push('index.html');
     90     info = await stat(join(ROOT, ...segments)).catch(() => null);
     91   }
     92   const file = join(ROOT, ...segments);
     93   if (!info?.isFile() || (await realRelative(file)) !== join(...segments)) return send(res, 404, 'Not found');
     94 
     95   res.writeHead(200, {
     96     'content-type': TYPES[extname(file).toLowerCase()] || 'application/octet-stream',
     97     'content-length': info.size,
     98     'cache-control': 'no-store',
     99     'x-content-type-options': 'nosniff',
    100   });
    101   if (req.method === 'HEAD') return res.end();
    102   createReadStream(file).on('error', () => res.destroy()).pipe(res);
    103 }
    104 
    105 const server = createServer((req, res) => {
    106   handle(req, res).catch((err) => {
    107     console.error(err);
    108     if (!res.headersSent) send(res, 500, 'Internal error');
    109     else res.destroy();
    110   });
    111 });
    112 
    113 server.on('error', (err) => {
    114   console.error(err.code === 'EADDRINUSE' ? `Port ${PORT} on ${HOST} is already in use. Set PORT to use another one.` : err.message);
    115   process.exit(1);
    116 });
    117 
    118 stat(join(ROOT, 'index.html')).then(
    119   () => server.listen(PORT, HOST, () => console.log(`Serving _site/ at http://${HOST}:${PORT}/ (Ctrl+C to stop)`)),
    120   () => {
    121     console.error('_site/index.html not found. Run `node tools/build-site.mjs` first, or `npm run serve`.');
    122     process.exit(1);
    123   },
    124 );