fix: CSP 策略移除 unsafe-inline,改用 nonce 机制

- SecurityHeaders 中间件生成随机 nonce,注入 context
- BuildPageData/BuildAdminPageData 将 CSPNonce 传递给模板
- 所有 19 个模板文件 <script> 标签添加 nonce 属性
- 移除所有内联事件处理(onclick/onerror),改用事件监听
- 移除所有 javascript:void(0) 链接,改用 # 号 + preventDefault
- utils.js 添加全局 avatar 图片加载失败回退处理
- common.js 登出按钮添加 preventDefault
- audit.js 关闭弹窗按钮改用事件监听
This commit is contained in:
2026-06-02 16:10:23 +08:00
parent 9477155bcf
commit 53f18ca6ee
25 changed files with 122 additions and 55 deletions

View File

@ -42,7 +42,7 @@
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
</button>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script src="/static/js/login.js?v={{assetV "/static/js/login.js"}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/login.js?v={{assetV "/static/js/login.js"}}" nonce="{{.CSPNonce}}"></script>
</body>
</html>

View File

@ -38,7 +38,7 @@
</label>
<span class="remember-hint">非共享设备推荐</span>
</div>
<p class="guidelines-link">点击注册即表示同意 <a href="javascript:void(0)" id="openGuidelines">《MetaLab 社区用户准则》</a></p>
<p class="guidelines-link">点击注册即表示同意 <a href="#" id="openGuidelines">《MetaLab 社区用户准则》</a></p>
<button type="submit" class="submit-btn" id="submitBtn">注 册</button>
</form>
{{else}}
@ -89,7 +89,7 @@
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
</button>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script src="/static/js/register.js?v={{assetV "/static/js/register.js"}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/register.js?v={{assetV "/static/js/register.js"}}" nonce="{{.CSPNonce}}"></script>
</body>
</html>

View File

@ -64,7 +64,7 @@
<div class="sidebar-card user-card">
{{if .IsLoggedIn}}
{{if .Avatar}}
<img src="{{.Avatar}}" alt="{{.Username}}" class="user-avatar-img" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
<img src="{{.Avatar}}" alt="{{.Username}}" class="user-avatar-img">
<div class="user-avatar-fallback" style="display:none">{{substr .Username 0 1}}</div>
{{else}}
<div class="user-avatar-fallback">{{substr .Username 0 1}}</div>
@ -117,6 +117,6 @@
{{template "layout/footer.html" .}}
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
</body>
</html>

View File

@ -20,5 +20,5 @@
{{if .ExtraCSS2}}
<link rel="stylesheet" href="{{.ExtraCSS2}}?v={{assetV .ExtraCSS2}}">
{{end}}
<script src="/shared/static/js/utils.js?v={{assetV "/shared/static/js/utils.js"}}"></script>
<script src="/shared/static/js/utils.js?v={{assetV "/shared/static/js/utils.js"}}" nonce="{{.CSPNonce}}"></script>
</head>

View File

@ -20,7 +20,7 @@
<li class="nav-avatar-wrapper">
<a href="/space" class="nav-avatar-link">
{{if .Avatar}}
<img src="{{.Avatar}}" alt="" class="nav-avatar" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
<img src="{{.Avatar}}" alt="" class="nav-avatar">
<span class="nav-avatar nav-avatar-default" style="display:none">{{substr .Username 0 1}}</span>
{{else}}
<span class="nav-avatar nav-avatar-default">{{substr .Username 0 1}}</span>
@ -30,7 +30,7 @@
<div class="nav-dropdown-header">
<div class="nav-dropdown-avatar">
{{if .Avatar}}
<img src="{{.Avatar}}" alt="" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
<img src="{{.Avatar}}" alt="">
<span style="display:none">{{substr .Username 0 1}}</span>
{{else}}
<span>{{substr .Username 0 1}}</span>
@ -47,7 +47,7 @@
<svg class="nav-dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span>个人中心</span>
</a>
<a href="javascript:void(0)" class="nav-dropdown-item nav-logout" id="logoutBtn">
<a href="#" class="nav-dropdown-item nav-logout" id="logoutBtn">
<svg class="nav-dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
<span>退出登录</span>
</a>

View File

@ -117,7 +117,7 @@
</main>
{{template "layout/footer.html" .}}
<script>
<script nonce="{{.CSPNonce}}">
(function () {
'use strict';
@ -230,6 +230,6 @@
}
})();
</script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
</body>
</html>

View File

@ -17,6 +17,6 @@
</div>
</div>
{{template "layout/footer.html" .}}
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
</body>
</html>

View File

@ -50,6 +50,6 @@
{{template "layout/footer.html" .}}
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
</body>
</html>

View File

@ -146,10 +146,10 @@
<textarea id="postMdContent" style="display:none">{{.Post.Body}}</textarea>
<link rel="stylesheet" href="/static/vditor/dist/index.css?v={{assetV "/static/vditor/dist/index.css"}}">
<script src="/static/vditor/dist/method.min.js?v={{assetV "/static/vditor/dist/method.min.js"}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script src="/static/js/shortcode.js?v={{assetV "/static/js/shortcode.js"}}"></script>
<script>
<script src="/static/vditor/dist/method.min.js?v={{assetV "/static/vditor/dist/method.min.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/shortcode.js?v={{assetV "/static/js/shortcode.js"}}" nonce="{{.CSPNonce}}"></script>
<script nonce="{{.CSPNonce}}">
(function() {
var el = document.getElementById('postContent');
var mdEl = document.getElementById('postMdContent');
@ -180,7 +180,7 @@
})();
</script>
<script>
<script nonce="{{.CSPNonce}}">
// 提交审核按钮
(function() {
var btn = document.getElementById('submitAuditBtn');
@ -205,7 +205,7 @@
})();
</script>
<script>
<script nonce="{{.CSPNonce}}">
// 赋能按钮 + 弹窗
(function() {
var btn = document.getElementById('energizeBtn');
@ -287,7 +287,7 @@
})();
</script>
<script>
<script nonce="{{.CSPNonce}}">
// ========== 赞/踩系统 ==========
(function() {
var container = document.getElementById('postReactions');
@ -363,7 +363,7 @@
})();
</script>
<script>
<script nonce="{{.CSPNonce}}">
// ========== 收藏按钮(弹窗选择收藏夹)==========
(function() {
var favBtn = document.getElementById('favBtn');
@ -614,7 +614,7 @@
})();
</script>
<script>
<script nonce="{{.CSPNonce}}">
// ========== 评论系统 ==========
(function() {
var commentsSection = document.getElementById('comments');

View File

@ -328,8 +328,8 @@
</div>
</div>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
<script nonce="{{.CSPNonce}}">
(function () {
'use strict';

View File

@ -338,18 +338,18 @@
{{if eq .ActiveTab "collections"}}
<!-- 左侧收藏夹列表 -->
<aside class="space-sidebar collection-sidebar">
<h2 class="space-sidebar-title collapsible" onclick="toggleFolderSection(this)">
<h2 class="space-sidebar-title collapsible" id="folderSectionToggle">
我创建的收藏夹
<svg class="collapse-arrow" viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
</h2>
<nav class="space-sidebar-nav" id="folder-nav">
<a href="javascript:void(0)" class="space-sidebar-link new-folder-btn" onclick="showNewFolderInput()">
<a href="#" class="space-sidebar-link new-folder-btn" id="showNewFolderBtn">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
新建收藏夹
</a>
<div id="new-folder-input" style="display:none;">
<input type="text" id="new-folder-name" placeholder="收藏夹名称" maxlength="50" class="new-folder-input">
<button onclick="createFolder()" class="new-folder-submit">确定</button>
<button id="createFolderBtn" class="new-folder-submit">确定</button>
</div>
{{if .FoldersResult}}
{{range $f := .FoldersResult.Folders}}
@ -576,11 +576,11 @@
{{template "layout/footer.html" .}}
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
<!-- 访客空间:关注按钮交互 -->
{{if and (not .IsOwnSpace) (or (eq .ActiveTab "following") (eq .ActiveTab "followers"))}}
<script>
<script nonce="{{.CSPNonce}}">
(function() {
var cards = document.querySelectorAll('.space-user-card');
if (!cards.length) return;
@ -678,7 +678,7 @@
<!-- 收藏夹交互脚本 -->
{{if eq .ActiveTab "collections"}}
<script>
<script nonce="{{.CSPNonce}}">
function toggleFolderSection(el) {
el.parentElement.querySelector('#folder-nav').classList.toggle('collapsed');
el.classList.toggle('collapsed');
@ -701,6 +701,18 @@ function createFolder() {
});
}
// 事件绑定(替代内联 onclick满足 CSP 无 unsafe-inline
document.addEventListener('DOMContentLoaded', function() {
var toggle = document.getElementById('folderSectionToggle');
if (toggle) toggle.addEventListener('click', function() { toggleFolderSection(this); });
var showBtn = document.getElementById('showNewFolderBtn');
if (showBtn) showBtn.addEventListener('click', function(e) { e.preventDefault(); showNewFolderInput(); });
var createBtn = document.getElementById('createFolderBtn');
if (createBtn) createBtn.addEventListener('click', function() { createFolder(); });
});
// 隐私开关变更保存
document.querySelectorAll('.privacy-settings-grid .toggle-switch input').forEach(function(cb) {
cb.addEventListener('change', function() {
@ -712,7 +724,7 @@ document.querySelectorAll('.privacy-settings-grid .toggle-switch input').forEach
<!-- 设置页交互脚本 -->
{{if eq .ActiveTab "settings"}}
<script>
<script nonce="{{.CSPNonce}}">
// 隐私开关变更时自动保存到后端
document.querySelectorAll('#privacy-follow-list, #privacy-follower-list').forEach(function(cb) {
cb.addEventListener('change', function() {

View File

@ -86,8 +86,8 @@
{{template "layout/footer.html" .}}
<script src="/static/js/chart.umd.min.js?v={{assetV "/static/js/chart.umd.min.js"}}"></script>
<script>
<script src="/static/js/chart.umd.min.js?v={{assetV "/static/js/chart.umd.min.js"}}" nonce="{{.CSPNonce}}"></script>
<script nonce="{{.CSPNonce}}">
(function () {
'use strict';

View File

@ -28,8 +28,8 @@
</div>
<div class="post-item-actions">
<a href="/studio/write?id={{.ID}}" class="action-btn primary">继续编辑</a>
<button class="action-btn" onclick="submitPost({{.ID}})" title="提交发布">提交</button>
<button class="action-btn danger" onclick="deletePost({{.ID}})" title="删除">删除</button>
<button class="action-btn" data-post-id="{{.ID}}" data-action="submit" title="提交发布">提交</button>
<button class="action-btn danger" data-post-id="{{.ID}}" data-action="delete" title="删除">删除</button>
</div>
</div>
{{end}}
@ -57,7 +57,7 @@
</main>
</div>
<script>
<script nonce="{{.CSPNonce}}">
function submitPost(id) {
if (!confirm('确定要提交这篇草稿进行审核吗?')) return;
@ -97,6 +97,19 @@ function deletePost(id) {
}
});
}
// 事件委托代替内联 onclick满足 CSP 无 unsafe-inline
document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var id = btn.getAttribute('data-post-id');
var action = btn.getAttribute('data-action');
if (action === 'delete') {
deletePost(id);
} else if (action === 'submit') {
submitPost(id);
}
});
</script>
{{template "layout/footer.html" .}}

View File

@ -49,7 +49,7 @@
<div class="post-item-actions">
{{if not .IsLocked}}<a href="/studio/write?id={{.ID}}" class="action-btn">编辑</a>{{end}}
<a href="/posts/{{.ID}}" target="_blank" class="action-btn">查看</a>
<button class="action-btn danger" onclick="deletePost({{.ID}})" title="删除">删除</button>
<button class="action-btn danger" data-post-id="{{.ID}}" data-action="delete" title="删除">删除</button>
</div>
</div>
{{end}}
@ -78,7 +78,7 @@
</main>
</div>
<script>
<script nonce="{{.CSPNonce}}">
function deletePost(id) {
if (!confirm('确定要删除这篇文章吗?')) return;
@ -98,6 +98,14 @@ function deletePost(id) {
}
});
}
// 事件委托代替内联 onclick满足 CSP 无 unsafe-inline
document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action="delete"]');
if (btn) {
deletePost(btn.getAttribute('data-post-id'));
}
});
</script>
{{template "layout/footer.html" .}}

View File

@ -95,8 +95,8 @@
{{template "layout/footer.html" .}}
<link rel="stylesheet" href="/static/vditor/dist/index.css?v={{assetV "/static/vditor/dist/index.css"}}">
<script src="/static/vditor/dist/index.min.js?v={{assetV "/static/vditor/dist/index.min.js"}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script src="/static/js/studio-editor.js?v={{assetV "/static/js/studio-editor.js"}}"></script>
<script src="/static/vditor/dist/index.min.js?v={{assetV "/static/vditor/dist/index.min.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/studio-editor.js?v={{assetV "/static/js/studio-editor.js"}}" nonce="{{.CSPNonce}}"></script>
</body>
</html>

View File

@ -258,7 +258,8 @@
// ---- Logout ----
var logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', function () {
logoutBtn.addEventListener('click', function (e) {
e.preventDefault();
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/logout', true);
xhr.onreadystatechange = function () {

View File

@ -92,12 +92,12 @@
<p>请填写拒绝理由:</p>
<textarea id="rejectReason" rows="3" maxlength="500" placeholder="请输入拒绝理由..."></textarea>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeRejectModal()">取消</button>
<button class="btn btn-outline" id="closeRejectModalBtn">取消</button>
<button class="btn btn-danger" id="confirmRejectBtn">确认拒绝</button>
</div>
</div>
</div>
<script>
<script nonce="{{.CSPNonce}}">
window.__AUDIT_TYPES = {
{{range $val, $label := .AuditTypes}}"{{$val}}": "{{$label}}",
{{end}}

View File

@ -30,4 +30,4 @@
<div class="admin-loading" id="commentLoading" style="display:none">加载中...</div>
{{template "admin/layout/footer.html" .}}
<script src="/admin/static/js/comments.js?v={{assetV "/admin/static/js/comments.js"}}"></script>
<script src="/admin/static/js/comments.js?v={{assetV "/admin/static/js/comments.js"}}" nonce="{{.CSPNonce}}"></script>

View File

@ -1,9 +1,9 @@
</main>
</div>
<script src="/shared/static/js/utils.js?v={{assetV "/shared/static/js/utils.js"}}"></script>
<script src="/admin/static/js/common.js?v={{assetV "/admin/static/js/common.js"}}"></script>
<script src="/shared/static/js/utils.js?v={{assetV "/shared/static/js/utils.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/admin/static/js/common.js?v={{assetV "/admin/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
{{if .ExtraJS}}
<script src="{{.ExtraJS}}"></script>
<script src="{{.ExtraJS}}" nonce="{{.CSPNonce}}"></script>
{{end}}
</body>
</html>

View File

@ -82,5 +82,5 @@
</div>
{{end}}
<script src="/admin/static/js/posts.js?v={{assetV "/admin/static/js/posts.js"}}"></script>
<script src="/admin/static/js/posts.js?v={{assetV "/admin/static/js/posts.js"}}" nonce="{{.CSPNonce}}"></script>
{{template "admin/layout/footer.html" .}}

View File

@ -44,7 +44,7 @@
<!-- 分页 -->
<div class="pagination-bar" id="paginationBar"></div>
<script>
<script nonce="{{.CSPNonce}}">
window.__currentUid = {{.UID}};
window.__isOwner = {{if .IsOwner}}true{{else}}false{{end}};
// 由服务端 model.RoleDisplayNames / StatusDisplayNames 作为唯一数据源注入

View File

@ -217,6 +217,8 @@ function closeRejectModal() {
document.getElementById('rejectModal').style.display = 'none';
}
document.getElementById('closeRejectModalBtn').addEventListener('click', closeRejectModal);
document.getElementById('confirmRejectBtn').addEventListener('click', async () => {
const reason = document.getElementById('rejectReason').value.trim();
if (!reason) {

View File

@ -12,3 +12,13 @@ function getCSRFToken() {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
// 全局头像图片加载失败回退(替代 onerror 内联事件,满足 CSP 无 unsafe-inline
document.addEventListener('error', function(e) {
var img = e.target;
if (img && img.tagName === 'IMG' && (img.classList.contains('nav-avatar') || img.classList.contains('user-avatar-img'))) {
img.style.display = 'none';
var next = img.nextElementSibling;
if (next) next.style.display = 'flex';
}
}, true);