security(dev): SSRF fix, WS limit, log rotation, BIND_HOST, sw.js API cache skip, .gitignore

- /api/check: add requireLocal + block 100.x/IPv6 in SSRF filter
- WebSocket: limit concurrent sessions to 3
- Session logs: auto-prune after 30 days
- server.listen: respect BIND_HOST env var
- sw.js: exclude /api/* from cache
- .gitignore: protect .env and node_modules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
posimai 2026-04-24 16:44:01 +09:00
parent 426057960a
commit 7c6ecb77bc
3 changed files with 31 additions and 5 deletions

3
posimai-dev/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.env
.env.local
node_modules/

View File

@ -25,6 +25,21 @@ const PORT = process.env.PORT || 3333;
const SESSIONS_DIR = path.join(os.homedir(), 'posimai-dev-sessions');
if (!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true });
// 30日超のセッションログを削除
function pruneSessionLogs() {
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
try {
fs.readdirSync(SESSIONS_DIR)
.filter(f => f.endsWith('.log'))
.forEach(f => {
const full = path.join(SESSIONS_DIR, f);
if (fs.statSync(full).mtimeMs < cutoff) fs.unlinkSync(full);
});
} catch (_) {}
}
pruneSessionLogs();
setInterval(pruneSessionLogs, 24 * 60 * 60 * 1000);
app.use(express.json());
app.use(express.static(path.join(__dirname)));
@ -235,7 +250,7 @@ app.get('/api/vps-health', async (req, res) => {
// ── サービス死活チェックプロキシ (/api/check?url=...) ──────────
// ブラウザの mixed-content 制限を回避するためサーバー側から HTTP チェック
// SSRF 対策: http/https のみ許可、クラウドメタデータ IP をブロック
const BLOCKED_HOSTS = /^(127\.|localhost$|::1$|169\.254\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|100\.100\.100\.100|metadata\.google\.internal)/i;
const BLOCKED_HOSTS = /^(127\.|localhost$|::1$|\[::1\]|0\.0\.0\.0|169\.254\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|100\.|metadata\.google\.internal)/i;
function isCheckUrlAllowed(raw) {
let parsed;
try { parsed = new URL(raw); } catch { return false; }
@ -244,7 +259,7 @@ function isCheckUrlAllowed(raw) {
return true;
}
app.get('/api/check', async (req, res) => {
app.get('/api/check', requireLocal, async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
const { url } = req.query;
if (!url) return res.status(400).json({ ok: false, error: 'url required' });
@ -286,6 +301,12 @@ wss.on('connection', (ws, req) => {
return;
}
// 同時セッション数を制限(自分自身を含むので size > 3 で実質3セッション上限
if (wss.clients.size > 3) {
ws.close(1013, 'Too many sessions');
return;
}
// セッションID・ログファイル作成
const sessionId = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const logPath = path.join(SESSIONS_DIR, `${sessionId}.log`);
@ -330,6 +351,7 @@ wss.on('connection', (ws, req) => {
ptyProc.onExit(() => { try { ws.close(); } catch {} });
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`posimai-dev running on ${proto}://0.0.0.0:${PORT}`);
const BIND_HOST = process.env.BIND_HOST || '0.0.0.0';
server.listen(PORT, BIND_HOST, () => {
console.log(`posimai-dev running on ${proto}://${BIND_HOST}:${PORT}`);
});

View File

@ -15,9 +15,10 @@ self.addEventListener('activate', (e) => {
self.clients.claim();
});
// ターミナルのWebSocket通信はキャッシュしない
// WebSocket・API レスポンスはキャッシュしない
self.addEventListener('fetch', (e) => {
if (e.request.url.includes('/terminal')) return;
if (new URL(e.request.url).pathname.startsWith('/api/')) return;
e.respondWith(
caches.match(e.request).then((r) => r || fetch(e.request))
);