diff --git a/_headers b/_headers index e9c45ca..ca7e0c7 100644 --- a/_headers +++ b/_headers @@ -1,3 +1,14 @@ +/* + Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; manifest-src 'self'; worker-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Referrer-Policy: no-referrer + Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=() + Strict-Transport-Security: max-age=31536000; includeSubDomains; preload + Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Resource-Policy: same-origin + Cross-Origin-Embedder-Policy: credentialless + /sw.js Cache-Control: no-cache, no-store, must-revalidate Service-Worker-Allowed: / diff --git a/app.js b/app.js index 602fcfa..b296e45 100644 --- a/app.js +++ b/app.js @@ -559,29 +559,57 @@ document.addEventListener('DOMContentLoaded', () => { window.addEventListener('pagehide', saveProgress); window.addEventListener('beforeunload', saveProgress); + const ALLOWED_SPECIES = ['green', 'loggerhead', 'leatherback']; + function clampStat(n) { + const v = Number(n); + if (!Number.isFinite(v)) return 0; + return Math.max(0, Math.min(100, v)); + } + function sanitizeName(n) { + if (typeof n !== 'string') return 'Shelly'; + // Strip control chars and HTML-relevant punctuation; cap at 12 like the input. + const cleaned = n.replace(/[-<>"'`\\]/g, '').trim().slice(0, 12); + return cleaned.length ? cleaned : 'Shelly'; + } + function loadProgress() { const saved = localStorage.getItem('tortugotchi_save'); if (!saved) return false; - try { - const data = JSON.parse(saved); - turtleName = data.name || 'Shelly'; - turtleSpecies = data.species || 'green'; - turtleAge = data.age || 0; - stats = data.stats || { health: 100, hunger: 80, joy: 80, clean: 100 }; - isSick = data.isSick || false; - - // Offline progression: apply the time the player was away. - // Capped at 24h of decay so a multi-week absence doesn't kill the turtle. - if (data.savedAt) { - const elapsedSec = Math.max(0, (Date.now() - data.savedAt) / 1000); - const cappedSec = Math.min(elapsedSec, 24 * 60 * 60); - applyOfflineProgression(cappedSec); - } - return true; - } catch (e) { - console.error("Failed to load save file, resetting.", e); + // Reject obviously oversized payloads (defends against extension-planted bloat). + if (saved.length > 4096) { + console.warn('Save file rejected: oversized.'); + localStorage.removeItem('tortugotchi_save'); return false; } + let data; + try { data = JSON.parse(saved); } catch (e) { + console.error('Failed to parse save file, resetting.', e); + return false; + } + if (!data || typeof data !== 'object' || Array.isArray(data)) return false; + + turtleName = sanitizeName(data.name); + turtleSpecies = ALLOWED_SPECIES.includes(data.species) ? data.species : 'green'; + const ageNum = Number(data.age); + turtleAge = Number.isFinite(ageNum) && ageNum >= 0 ? Math.floor(ageNum) : 0; + + const s = data.stats && typeof data.stats === 'object' && !Array.isArray(data.stats) + ? data.stats : {}; + stats = { + health: clampStat(s.health ?? 100), + hunger: clampStat(s.hunger ?? 80), + joy: clampStat(s.joy ?? 80), + clean: clampStat(s.clean ?? 100) + }; + isSick = data.isSick === true; + + const savedAt = Number(data.savedAt); + if (Number.isFinite(savedAt) && savedAt > 0 && savedAt <= Date.now()) { + const elapsedSec = Math.max(0, (Date.now() - savedAt) / 1000); + const cappedSec = Math.min(elapsedSec, 24 * 60 * 60); + applyOfflineProgression(cappedSec); + } + return true; } function applyOfflineProgression(seconds) { @@ -2307,12 +2335,8 @@ document.addEventListener('DOMContentLoaded', () => { }); btnSaveRename.addEventListener('click', () => { - const inputName = document.getElementById('input-turtle-name').value.trim(); - if (inputName.length > 0) { - turtleName = inputName; - } else { - turtleName = 'Shelly'; - } + const raw = document.getElementById('input-turtle-name').value; + turtleName = sanitizeName(raw); modals.rename.classList.add('hidden'); diff --git a/sw.js b/sw.js index 4f05b21..8f10c80 100644 --- a/sw.js +++ b/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'tortugotchi-v10'; +const CACHE_NAME = 'tortugotchi-v11'; const ASSETS = [ './', './index.html', @@ -9,43 +9,40 @@ const ASSETS = [ './icon-512.png' ]; -// Install Event self.addEventListener('install', (e) => { e.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - console.log('[Service Worker] Caching all app shell assets'); - return cache.addAll(ASSETS); - }).then(() => self.skipWaiting()) + caches.open(CACHE_NAME) + .then((cache) => cache.addAll(ASSETS)) + .then(() => self.skipWaiting()) ); }); -// Activate Event self.addEventListener('activate', (e) => { e.waitUntil( - caches.keys().then((keys) => { - return Promise.all( - keys.map((key) => { - if (key !== CACHE_NAME) { - console.log('[Service Worker] Removing old cache', key); - return caches.delete(key); - } - }) - ); - }).then(() => self.clients.claim()) + caches.keys() + .then((keys) => Promise.all( + keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) + )) + .then(() => self.clients.claim()) ); }); -// Fetch Event self.addEventListener('fetch', (e) => { + const req = e.request; + + // Only handle GETs over http(s) to our own origin. Skip POST/PUT/DELETE, + // chrome-extension://, data:, blob:, ws:, and cross-origin requests so the + // worker can never be coerced into serving the wrong response. + if (req.method !== 'GET') return; + let url; + try { url = new URL(req.url); } catch { return; } + if (url.protocol !== 'https:' && url.protocol !== 'http:') return; + if (url.origin !== self.location.origin) return; + e.respondWith( - caches.match(e.request).then((cachedResponse) => { - // Return cached version or fetch from network - return cachedResponse || fetch(e.request).catch(() => { - // Fallback for document requests if completely offline - if (e.request.destination === 'document') { - return caches.match('./index.html'); - } - }); - }) + caches.match(req).then((cached) => cached || fetch(req).catch(() => { + if (req.destination === 'document') return caches.match('./index.html'); + return new Response('', { status: 504, statusText: 'Offline' }); + })) ); });