fix(posimai-sc): セキュリティ・品質監査対応(CSP/DOMPurify/reactivity 他)

- [H1] CSP meta タグ追加(script/style/connect/frame-ancestors 等)
- [H2] posimai_api_key を sessionStorage に移行(localStorage フォールバック付き)
- [H3] DOMPurify を USE_PROFILES から明示 ALLOWED_TAGS/ALLOWED_ATTR ホワイトリストに変更
- [M1] progress/scores の直接変更を spread 再代入に統一(Alpine reactivity 保証)
- [M2] .catch(()=>{}) を console.warn 付きに変更(サイレント消滅を防止)
- [M3] syncHeader を this.syncToken 一本化(localStorage 二重読み解消)
- [M5] SW の cache.put() 失敗を捕捉してログ出力
- [M6/M7] sidebar-item に aria-label/aria-pressed 追加、main に tabindex=-1 + focus()
- [LOW] glossary x-for キーをコンテンツベースに変更、SW コメント補足
- SW v7 → v8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
posimai 2026-04-20 22:42:55 +09:00
parent 50e95577d7
commit 96208363d9
3 changed files with 38 additions and 16 deletions

View File

@ -2,6 +2,19 @@
<html lang="ja" data-app-id="posimai-sc"> <html lang="ja" data-app-id="posimai-sc">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' https://cdn.jsdelivr.net https://unpkg.com 'unsafe-inline' 'unsafe-eval';
style-src 'self' https://fonts.googleapis.com 'unsafe-inline';
font-src https://fonts.gstatic.com;
connect-src 'self' https://api.soar-enrich.com;
img-src 'self' data:;
worker-src 'self';
manifest-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self'
">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<script> <script>
(function(){ (function(){
@ -421,6 +434,8 @@ header{display:flex;align-items:center;justify-content:space-between;padding:0 1
<template x-for="u in cat.units" :key="u.id"> <template x-for="u in cat.units" :key="u.id">
<div class="sidebar-item" <div class="sidebar-item"
role="button" tabindex="0" role="button" tabindex="0"
:aria-label="u.num + ': ' + u.title"
:aria-pressed="!!(currentUnit && currentUnit.id===u.id)"
:class="{active: currentUnit && currentUnit.id===u.id, done: isDone(u.id)}" :class="{active: currentUnit && currentUnit.id===u.id, done: isDone(u.id)}"
@click="openUnit(u); sidebarOpen=false" @click="openUnit(u); sidebarOpen=false"
@keydown.enter.prevent="openUnit(u); sidebarOpen=false" @keydown.enter.prevent="openUnit(u); sidebarOpen=false"
@ -436,7 +451,7 @@ header{display:flex;align-items:center;justify-content:space-between;padding:0 1
</template> </template>
</nav> </nav>
<main id="main"> <main id="main" tabindex="-1">
<!-- HOME --> <!-- HOME -->
<div x-show="!currentUnit && !weakDrillActive && !glossaryViewActive && !examActive"> <div x-show="!currentUnit && !weakDrillActive && !glossaryViewActive && !examActive">
<div class="home-hero"> <div class="home-hero">
@ -549,7 +564,7 @@ header{display:flex;align-items:center;justify-content:space-between;padding:0 1
</div> </div>
<div class="glossary-meta-line" x-text="filteredGlossary.length + ' 件 / 全 ' + glossaryRows.length + ' 件'"></div> <div class="glossary-meta-line" x-text="filteredGlossary.length + ' 件 / 全 ' + glossaryRows.length + ' 件'"></div>
<div class="glossary-list"> <div class="glossary-list">
<template x-for="(row, gi) in filteredGlossary" :key="'g'+gi+'-'+row.unitId+'-'+row.term"> <template x-for="row in filteredGlossary" :key="row.unitId+'-'+row.term">
<div class="glossary-row" role="button" tabindex="0" <div class="glossary-row" role="button" tabindex="0"
@click="openUnitFromGlossary(row.unitId)" @click="openUnitFromGlossary(row.unitId)"
@keydown.enter.prevent="openUnitFromGlossary(row.unitId)" @keydown.enter.prevent="openUnitFromGlossary(row.unitId)"

View File

@ -82,7 +82,8 @@ function sanitizeTrustedHtml(dirty) {
return d.innerHTML; return d.innerHTML;
} }
return window.DOMPurify.sanitize(s, { return window.DOMPurify.sanitize(s, {
USE_PROFILES: { html: true, svg: true, svgFilters: true }, ALLOWED_TAGS: ['p', 'div', 'span', 'strong', 'em', 'br', 'ul', 'ol', 'li', 'code', 'pre'],
ALLOWED_ATTR: ['class'],
ALLOW_UNKNOWN_PROTOCOLS: false ALLOW_UNKNOWN_PROTOCOLS: false
}); });
} }
@ -154,7 +155,8 @@ document.addEventListener('alpine:init', () => {
catch{ this.wrongUnits = {}; } catch{ this.wrongUnits = {}; }
this.syncStateAt = parseInt(localStorage.getItem('posimai-sc-state-at') || '0', 10) || 0; this.syncStateAt = parseInt(localStorage.getItem('posimai-sc-state-at') || '0', 10) || 0;
const jwtToken = (localStorage.getItem('posimai_token') || '').trim(); const jwtToken = (localStorage.getItem('posimai_token') || '').trim();
const apiKey = (localStorage.getItem(SYNC_KEY_LS) || '').trim(); // posimai_api_key: sessionStorage 優先、旧 localStorage からの移行フォールバック
const apiKey = (sessionStorage.getItem(SYNC_KEY_LS) || localStorage.getItem(SYNC_KEY_LS) || '').trim();
this.syncToken = jwtToken || apiKey; this.syncToken = jwtToken || apiKey;
this.syncEnabled = !!this.syncToken; this.syncEnabled = !!this.syncToken;
const t = localStorage.getItem('posimai-sc-theme')||'system'; const t = localStorage.getItem('posimai-sc-theme')||'system';
@ -171,12 +173,12 @@ document.addEventListener('alpine:init', () => {
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); }); this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
}); });
if(this.syncEnabled){ if(this.syncEnabled){
this.pullCloudState().catch(()=>{}); this.pullCloudState().catch(e=>{ console.warn('[sc] pullCloudState failed:', e); });
} else { } else {
this.syncStatus = '同期キー未設定'; this.syncStatus = '同期キー未設定';
} }
if('serviceWorker' in navigator){ if('serviceWorker' in navigator){
navigator.serviceWorker.register('/sw.js').catch(()=>{}); navigator.serviceWorker.register('/sw.js').catch(e=>{ console.warn('[sc] SW register failed:', e); });
} }
document.addEventListener('keydown',(e)=>{ document.addEventListener('keydown',(e)=>{
const t=e.target; const t=e.target;
@ -238,7 +240,7 @@ document.addEventListener('alpine:init', () => {
if(this.syncEnabled) this.scheduleCloudPush(); if(this.syncEnabled) this.scheduleCloudPush();
}, },
get syncHeader(){ get syncHeader(){
const token = (localStorage.getItem('posimai_token') || '').trim() || (this.syncToken || '').trim(); const token = (this.syncToken || '').trim();
return token ? { Authorization: 'Bearer ' + token } : null; return token ? { Authorization: 'Bearer ' + token } : null;
}, },
async pullCloudState(){ async pullCloudState(){
@ -303,7 +305,7 @@ document.addEventListener('alpine:init', () => {
}, },
scheduleCloudPush(){ scheduleCloudPush(){
if(this.syncTimer) clearTimeout(this.syncTimer); if(this.syncTimer) clearTimeout(this.syncTimer);
this.syncTimer = setTimeout(()=>{ this.pushCloudState().catch(()=>{}); }, 800); this.syncTimer = setTimeout(()=>{ this.pushCloudState().catch(e=>{ console.warn('[sc] pushCloudState failed:', e); }); }, 800);
}, },
enableSyncWithInput(){ enableSyncWithInput(){
const token = (this.syncToken || '').trim(); const token = (this.syncToken || '').trim();
@ -311,9 +313,9 @@ document.addEventListener('alpine:init', () => {
this.syncStatus = '同期キー未設定'; this.syncStatus = '同期キー未設定';
return; return;
} }
localStorage.setItem(SYNC_KEY_LS, token); sessionStorage.setItem(SYNC_KEY_LS, token);
this.syncEnabled = true; this.syncEnabled = true;
this.pullCloudState().catch(()=>{}); this.pullCloudState().catch(e=>{ console.warn('[sc] pullCloudState failed:', e); });
}, },
async syncNow(){ async syncNow(){
if(!this.syncEnabled){ if(!this.syncEnabled){
@ -393,7 +395,7 @@ document.addEventListener('alpine:init', () => {
isDone(id){ return !!this.progress[id]; }, isDone(id){ return !!this.progress[id]; },
markDone(){ markDone(){
if(!this.currentUnit) return; if(!this.currentUnit) return;
this.progress[this.currentUnit.id]=true; this.progress = {...this.progress, [this.currentUnit.id]: true};
localStorage.setItem('posimai-sc-progress',JSON.stringify(this.progress)); localStorage.setItem('posimai-sc-progress',JSON.stringify(this.progress));
this.markStateChanged(); this.markStateChanged();
}, },
@ -412,7 +414,7 @@ document.addEventListener('alpine:init', () => {
saveScore(id,correct,total){ saveScore(id,correct,total){
const prev=this.scores[id]; const prev=this.scores[id];
if(!prev||correct>prev.best){ if(!prev||correct>prev.best){
this.scores[id]={best:correct,total}; this.scores = {...this.scores, [id]: {best:correct, total}};
localStorage.setItem('posimai-sc-scores',JSON.stringify(this.scores)); localStorage.setItem('posimai-sc-scores',JSON.stringify(this.scores));
this.markStateChanged(); this.markStateChanged();
} }
@ -446,7 +448,7 @@ document.addEventListener('alpine:init', () => {
this.$nextTick(()=>{ this.$nextTick(()=>{
if(window.lucide) lucide.createIcons(); if(window.lucide) lucide.createIcons();
const m=document.getElementById('main'); const m=document.getElementById('main');
if(m) m.scrollTo({top:0,behavior:'smooth'}); if(m){ m.scrollTo({top:0,behavior:'smooth'}); m.focus({preventScroll:true}); }
}); });
}, },
@ -462,7 +464,7 @@ document.addEventListener('alpine:init', () => {
delete this.wrongUnits[uid]; delete this.wrongUnits[uid];
// auto-mark done on perfect score // auto-mark done on perfect score
if(!this.progress[uid]){ if(!this.progress[uid]){
this.progress[uid]=true; this.progress = {...this.progress, [uid]: true};
try{ localStorage.setItem('posimai-sc-progress',JSON.stringify(this.progress)); }catch(e){} try{ localStorage.setItem('posimai-sc-progress',JSON.stringify(this.progress)); }catch(e){}
this.markStateChanged(); this.markStateChanged();
} }

View File

@ -1,5 +1,5 @@
// posimai-sc SW — same-origin の静的資産のみキャッシュCDN は対象外) // posimai-sc SW — same-origin の静的資産のみキャッシュCDN は対象外)
const CACHE = 'posimai-sc-v7'; const CACHE = 'posimai-sc-v8';
const STATIC = [ const STATIC = [
'/', '/',
'/index.html', '/index.html',
@ -48,7 +48,12 @@ self.addEventListener('fetch', e => {
cache.match(e.request).then(cached => { cache.match(e.request).then(cached => {
const network = fetch(e.request) const network = fetch(e.request)
.then(res => { .then(res => {
if (res.ok && res.type === 'basic') cache.put(e.request, res.clone()); // type:'basic' = same-origin only; CORS レスポンスはキャッシュしない
if (res.ok && res.type === 'basic') {
cache.put(e.request, res.clone()).catch(err => {
console.warn('[sc-sw] cache.put failed (quota?):', err);
});
}
return res; return res;
}) })
.catch(() => cached || new Response('Offline', { status: 503, statusText: 'Service Unavailable' })); .catch(() => cached || new Response('Offline', { status: 503, statusText: 'Service Unavailable' }));