从 React 来,降落在 SvelteKit
这是整个栈中对我来说最新的工具,也是同时做最多事情的工具。SvelteKit 在服务器端渲染页面,处理表单提交,从一个 Node 进程代理 API 调用到两个 Go 服务。之前我主要是 React 和 MERN 栈背景,一个页面加载和表单提交都在同一个文件里的概念,加上并排放着一个 load 函数和一个 actions 对象,用了一阵才习惯。它不是一个单页应用从客户端 JavaScript 调用 REST API,这里的大部分重要东西根本不会碰到浏览器自己的脚本引擎。

浏览器实际上从不拿着令牌
这是我在整个项目里最保护的一个架构决定。证明你身份的访问令牌存在一个 httponly cookie 中,这意味着浏览器中运行的任何 JavaScript,无论是我的,还是浏览器扩展,还是某个 XSS 负载(如果哪天一个溜进来了),都读不了它。但它仍然必须从那个 cookie 进入 Go 服务期望的 Authorization 头,而那个转换完全在服务器端,在 SvelteKit 内部发生。
每个到后端的请求都通过前端服务器上的一个捕获所有路由代理。浏览器调用前端自己来源上的 /api/whatever,SvelteKit 在服务器端读那个 cookie 并在转发请求前把它作为一个真正的 bearer 令牌附加上去。
export async function forwardRequest(
event: RequestEvent,
target: URL,
extraHeaders: Record<string, string> = {}
): Promise<Response> {
const allowed = getAllowedOrigins();
if (!isAllowedTarget(target, allowed)) {
throw new Error(`target origin not in allowed origins: ${target.origin}`);
}
const headers = new Headers(extraHeaders);
const contentType = event.request.headers.get('content-type');
if (contentType) headers.set('content-type', contentType);
...
return fetch(target, init);
}那个 isAllowedTarget 对固定白名单内部来源的检查就是这样,一个上游某处的 bug 不会被骗成用我们的认证头转发请求给某个任意主机,基本上是针对看起来就像普通代理函数的东西的 SSRF 保护。
会话状态,不做验证,是故意的
令牌让我困惑了一阵的另一个地方:hooks.server.ts 在每个请求上都运行,决定那个请求谁登录了,但它只解码 JWT,完全不验证签名。
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = null;
let sessionFromAccessToken = false;
const accessToken = event.cookies.get('access_token');
if (accessToken) {
const claims = decodeAccessToken(accessToken);
if (claims && !isExpired(claims)) {
event.locals.user = claimsToLocalsUser(claims);
sessionFromAccessToken = true;
}
}
if (!sessionFromAccessToken) {
const refreshToken = event.cookies.get('refresh_token');
if (refreshToken) {
const response = await event.fetch('/auth/refresh', { method: 'POST' });
...
}
}
...
};第一次看这东西时读起来像个安全洞,如果 locals.user 曾被当作任何东西的证明就会是。不过它不是。它只决定页面渲染什么:管理员链接是否在导航里显示,一个表单是显示"登出"还是"登入"。每个真正的写入仍然通过 Go 服务,它们是在做任何事情前根据认证的公钥验证签名的那些。如果有人伪造了一个声称是管理员的 cookie,UI 会很乐意地在导航里显示他们管理员链接,然后他们做的每个请求都会在另一端真正的检查上被拒绝。装饰品在这一边,支撑承重的在那一边。
一个表单动作,不是一个 fetch 调用
登入是一个普通的 HTML 表单,作为一个 SvelteKit 表单动作提交,这意味着即使 JavaScript 关闭了也能用:
export const actions: Actions = {
default: async ({ request, fetch, url }) => {
const form = await request.formData();
const username = String(form.get('username') ?? '');
const password = String(form.get('password') ?? '');
let response: Response;
try {
response = await fetch('/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password })
});
} catch (err) {
const apiError = await parseApiError(err);
return fail(502, { error: apiError.message, requestId: apiError.requestId, username });
}
if (!response.ok) {
const apiError = await parseApiError(response);
return fail(response.status, { error: apiError.message, requestId: apiError.requestId, username });
}
throw redirect(303, safeRedirectTarget(url.searchParams.get('redirectTo')));
}
};那个 fetch 调用是 SvelteKit 自己的服务器端 fetch,运行在表单动作内部,打击内部 /auth/login 路由,那本身就是上面的代理。浏览器只看到一个普通的表单 POST 和最后一个重定向。
我第一次接好这个时一直试着从像我在 React 中一样的一个组件里的 <script> 直接调用 auth 服务:
<script lang="ts">
// 我首先试的,直接从 React 习惯
async function login(username: string, password: string) {
const res = await fetch('http://localhost:8081/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
}
</script>一直在碰到 CORS,因为那是一个浏览器直接调用一个完全不同的来源,最终接受了不,那不是这里怎么工作的,服务器改为做它。
不能运行脚本的 markdown
帖子内容和评论都作为 markdown 写入并渲染成 HTML,这意味着用户输入最终变成页面上的真正 DOM。那如果你不小心就是存储 XSS 的教科书设置。渲染通过 marked 然后直接通过 DOMPurify,带一个显式白名单,不是黑名单:
const POST_ALLOWED_TAGS = [
'p', 'br', 'strong', 'em', 'del', 'a', 'ul', 'ol', 'li',
'h2', 'h3', 'h4', 'blockquote', 'code', 'pre', 'span',
'img', 'video', 'hr', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
];
const COMMENT_ALLOWED_TAGS = ['p', 'br', 'a', 'strong', 'em', 'code'];
const COMMENT_ALLOWED_ATTR = ['href'];帖子得到比评论更宽的白名单,因为只有管理员写帖子,博客自己的 API 在 create/update 端点拒绝任何其他人,所以我信任那个内容更多。评论来自互联网上的匿名陌生人,所以他们得到最小的列表,没有图像,没有样式,几乎不超过一个段落和一个链接。甚至 video 标签在白名单之上还得到一个额外检查,一个钩子只让 <video> 元素保留它的 src 如果那个 URL 的来源匹配我们自己的媒体主机,所以一个评论不能把一个 video 标签指向某个完全不同的域。
一个小系统,但它是一个地方我觉得我真的理解了一个真正的安全原则(白名单打败黑名单,每一次)而不只是在一个讲座中被告知它并点头同意。