Security hardening: CSP headers, SW scope gating, save validation

- _headers: add CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, HSTS, COOP, CORP, COEP
- sw.js: gate fetch handler to GET + http(s) + same-origin; return 504 on offline non-document failures; bump cache to v11
- app.js: validate every field of the localStorage save (allowlist species, clamp stats, coerce age, reject oversized payloads, strip HTML-relevant chars from name); apply same sanitizer to rename input
This commit is contained in:
Joel Brock
2026-05-27 12:41:11 -07:00
parent f52f99b647
commit 7bc83a782d
3 changed files with 83 additions and 51 deletions
+11
View File
@@ -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: /
+48 -24
View File
@@ -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');
+24 -27
View File
@@ -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' });
}))
);
});