保护服务端渲染应用

本指南介绍 AdonisJS 服务端渲染应用的安全特性。你将学习如何:

  • 保护表单免受 CSRF(跨站请求伪造)攻击
  • 定义 CSP(内容安全策略)规则以防止 XSS 攻击
  • 配置 HSTS 以强制使用 HTTPS 连接
  • 使用 X-Frame-Options 响应头防止点击劫持
  • 禁用 MIME 嗅探以避免内容类型攻击

概述

Web 应用面临着持续的安全威胁。攻击者利用表单提交伪造、恶意脚本注入和点击劫持等漏洞来危害你的用户。@adonisjs/shield 包提供了一个统一的防御层,保护你的服务端渲染 AdonisJS 应用免受这些常见攻击。

Shield 通过向你的应用添加以安全为核心的 HTTP 响应头和中间件来工作。你无需分别配置每种防护措施,Shield 为你提供了一个带有合理默认值的单一包,你可以根据需要对其进行自定义。所有防护都在 config/shield.ts 中配置,使得审计和调整你的安全态势变得容易。

该包已预配置在 Web starter kit 中。如果你需要手动安装它,请确保先配置好 @adonisjs/session 包,因为 Shield 依赖 session 来存储 CSRF 令牌。

node ace add @adonisjs/shield
查看 add 命令执行的步骤
  1. 使用检测到的包管理器安装 @adonisjs/shield 包。

  2. adonisrc.ts 文件中注册以下服务提供者。

    {
      providers: [
        // ...其他提供者
        () => import('@adonisjs/shield/shield_provider'),
      ]
    }
  3. 创建 config/shield.ts 文件。

  4. start/kernel.ts 文件中注册以下中间件。

    router.use([() => import('@adonisjs/shield/shield_middleware')])

CSRF 防护

CSRF(跨站请求伪造)攻击会在用户在不知情的情况下,诱使已身份验证的用户提交恶意请求。想象一下,一个用户登录了你的银行应用。在浏览另一个站点时,那个恶意站点包含一个隐藏的表单,向你的银行提交一笔转账请求。因为用户的浏览器会自动带上他们的 session cookie,银行就会把这个转账当作是用户自己发起的来处理。

Shield 通过要求每次表单提交都带有一个机密令牌来防止 CSRF 攻击。该令牌在服务端生成,并嵌入到你的表单中。由于攻击者无法从他们的恶意站点访问这个令牌,他们伪造的请求将被拒绝。

保护表单

配置好 Shield 后,所有没有有效 CSRF 令牌的表单提交都会自动失败。你必须使用 csrfField Edge 辅助函数,在每一个表单中包含该令牌,它会渲染一个包含令牌的隐藏输入字段。

resources/views/posts/create.edge
<form method="POST" action="/posts">
  {{-- 渲染一个包含 CSRF 令牌的隐藏输入 --}}
  {{ csrfField() }}

  <input type="text" name="title" placeholder="Post title">
  <textarea name="content" placeholder="Write your post..."></textarea>
  <button type="submit">Create Post</button>
</form>

该辅助函数会生成一个隐藏输入字段,Shield 的中间件会在提交时对其进行验证。

Output HTML
<form method="POST" action="/posts">
  <input type="hidden" name="_csrf" value="Q9ghWSf0-3FD9eCiu5YxvKaxLEZ6F_K4DL8o"/>
  
  <input type="text" name="title" placeholder="Post title">
  <textarea name="content" placeholder="Write your post..."></textarea>
  <button type="submit">Create Post</button>
</form>

处理 CSRF 错误

当令牌缺失或无效时,Shield 会抛出 E_BAD_CSRF_TOKEN 异常。默认情况下,AdonisJS 会带着一个错误闪现消息将用户重定向回表单。你可以使用 @error 标签在模板中显示这条消息。

resources/views/posts/create.edge
@error('E_BAD_CSRF_TOKEN')
  <p class="error">{{ $message }}</p>
@end

<form method="POST" action="/posts">
  {{ csrfField() }}
  {{-- 表单字段 --}}
</form>

对于自定义错误处理,你可以在全局异常处理程序中捕获该异常。当你想要渲染一个自定义错误页面或返回特定格式的响应时,这会很有用。

app/exceptions/handler.ts
import app from '@adonisjs/core/services/app'
import { errors } from '@adonisjs/shield'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'

export default class HttpExceptionHandler extends ExceptionHandler {
  async handle(error: unknown, ctx: HttpContext) {
    /**
     * 检查错误是否为 CSRF 令牌错误,并
     * 返回自定义响应,而不是默认的重定向。
     */
    if (error instanceof errors.E_BAD_CSRF_TOKEN) {
      return ctx.response
        .status(error.status)
        .send('Your session has expired. Please refresh the page and try again.')
    }

    return super.handle(error, ctx)
  }
}

为 Ajax 请求启用 CSRF 令牌

单页应用和交互式界面经常通过 JavaScript 而不是传统的表单提交来提交表单。对于这些情况,Shield 可以将 CSRF 令牌暴露在 cookie 中,供你的前端代码读取。

当启用 enableXsrfCookie 时,Shield 会将令牌存储在一个名为 XSRF-TOKEN 的加密 cookie 中。像 Axios 这样的前端库会自动读取这个 cookie,并将其作为 X-XSRF-TOKEN 头随每个请求一并包含。

config/shield.ts
import { defineConfig } from '@adonisjs/shield'

const shieldConfig = defineConfig({
  csrf: {
    enabled: true,
    exceptRoutes: [],
    enableXsrfCookie: true, 
    methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
  },
})

export default shieldConfig
Tip

只有当你的应用发起 Ajax 请求时,才启用 enableXsrfCookie。对于使用整页提交的传统服务端渲染表单,隐藏输入字段就足够了,而且更安全。

将路由排除在 CSRF 防护之外

接收来自外部服务的 webhook 或请求的 API 端点无法包含 CSRF 令牌。你可以使用 exceptRoutes 选项将特定路由排除在外。

config/shield.ts
import { defineConfig } from '@adonisjs/shield'

const shieldConfig = defineConfig({
  csrf: {
    enabled: true,
    exceptRoutes: [
      '/api/webhooks/*',
      '/api/payments/callback',
    ],
    enableXsrfCookie: false,
    methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
  },
})

export default shieldConfig

对于动态的排除逻辑,传入一个接收 HTTP 上下文并返回布尔值的函数。

config/shield.ts
import { defineConfig } from '@adonisjs/shield'

const shieldConfig = defineConfig({
  csrf: {
    enabled: true,
    exceptRoutes: (ctx) => {
      /**
       * 将所有以 /api/ 开头的路由排除,
       * 因为它们由拥有自身身份验证机制的
       * 外部服务消费。
       */
      return ctx.request.url().startsWith('/api/')
    },
    enableXsrfCookie: false,
    methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
  },
})

export default shieldConfig

CSRF 配置参考

OptionTypeDescription
enabledboolean开启或关闭 CSRF 防护。
exceptRoutesstring[]function被排除在 CSRF 防护之外的路由。接受路由模式,或接收 HttpContext 的函数。
enableXsrfCookiebooleantrue 时,将 CSRF 令牌存储在 XSRF-TOKEN cookie 中,供 Ajax 请求使用。
methodsstring[]需要 CSRF 令牌验证的 HTTP 方法。默认为 POSTPUTPATCHDELETE
cookieOptionsobjectXSRF-TOKEN cookie 的配置。参见 cookie 配置

CSP(内容安全策略)

XSS(跨站脚本)攻击会将恶意脚本注入到你的页面中。攻击者可能会利用一个未对输入进行净化的评论表单,注入一段会窃取用户 cookie 或将用户重定向到钓鱼站点的 JavaScript。即使进行了正确的输入净化,XSS 漏洞也可能溜过去。

CSP 通过告诉浏览器哪些内容来源是可信的,提供了第二道防线。当你定义了 CSP 策略时,浏览器会阻止任何与你允许的来源不匹配的脚本、样式或其他资源。即使攻击者成功注入了一个 script 标签,浏览器也会拒绝执行它,因为它不是从可信来源加载的。

启用 CSP

CSP 默认是禁用的,因为策略必须根据你的应用需求量身定制。在配置文件中启用它并定义你的指令。

config/shield.ts
import { defineConfig } from '@adonisjs/shield'

const shieldConfig = defineConfig({
  csp: {
    enabled: true,
    directives: {
      defaultSrc: [`'self'`],
      scriptSrc: [`'self'`, 'https://cdnjs.cloudflare.com'],
      styleSrc: [`'self'`, 'https://fonts.googleapis.com'],
      fontSrc: [`'self'`, 'https://fonts.gstatic.com'],
      imgSrc: [`'self'`, 'data:', 'https://images.example.com'],
    },
    reportOnly: false,
  },
})

export default shieldConfig

defaultSrc 指令充当任何你未显式配置的资源类型的回退。'self' 关键字允许来自你自己域名的资源。每个指令控制一种特定的资源类型:scriptSrc 对应 JavaScript,styleSrc 对应 CSS,fontSrc 对应字体,依此类推。

你可以在 content-security-policy.com 找到可用指令的完整列表。

为内联脚本和样式使用 nonce

内联脚本和样式默认在 CSP 下是被阻止的,因为它们是常见的 XSS 攻击载体。但是,你可能有正当理由需要内联代码。Nonce(一次性数字)允许特定的内联块,同时保持整体策略的严格性。

@nonce 关键字添加到你的指令中,然后使用 Edge 模板中可用的 cspNonce 变量,在内联的 script 和 style 标签上包含 nonce 属性。

config/shield.ts
import { defineConfig } from '@adonisjs/shield'

const shieldConfig = defineConfig({
  csp: {
    enabled: true,
    directives: {
      defaultSrc: [`'self'`],
      scriptSrc: [`'self'`, '@nonce'],
      styleSrc: [`'self'`, '@nonce'],
    },
    reportOnly: false,
  },
})

export default shieldConfig
resources/views/pages/home.edge
<script nonce="{{ cspNonce }}">
  // 这段内联脚本会执行,因为它有一个有效的 nonce
  console.log('Application initialized')
</script>

<style nonce="{{ cspNonce }}">
  /* 这段内联样式会应用,因为它有一个有效的 nonce */
  .highlight { background: yellow; }
</style>

Shield 会为每个请求生成一个唯一的 nonce。攻击者无法预测这个值,因此即使他们注入了一个 script 标签,它也不会拥有有效的 nonce,浏览器会阻止它。

为 Vite 配置 CSP

当使用 Vite 进行资源打包时,你需要在开发期间允许来自 Vite 开发服务器的资源。Shield 为此提供了特殊的关键字。

config/shield.ts
import { defineConfig } from '@adonisjs/shield'

const shieldConfig = defineConfig({
  csp: {
    enabled: true,
    directives: {
      defaultSrc: [`'self'`, '@viteDevUrl'],
      connectSrc: [`'self'`, '@viteHmrUrl'],
      scriptSrc: [`'self'`, '@nonce'],
      styleSrc: [`'self'`, '@nonce'],
    },
    reportOnly: false,
  },
})

export default shieldConfig

@viteDevUrl 关键字解析为 Vite 开发服务器的 URL,而 @viteHmrUrl 允许用于热模块替换(HMR)的 WebSocket 连接。

如果你将打包后的资源部署到 CDN,请将 @viteDevUrl 替换为 @viteUrl。这个关键字同时允许来自开发服务器和生产环境 CDN 的资源。

config/shield.ts
import { defineConfig } from '@adonisjs/shield'

const shieldConfig = defineConfig({
  csp: {
    enabled: true,
    directives: {
      defaultSrc: [`'self'`, '@viteUrl'], 
      connectSrc: [`'self'`, '@viteHmrUrl'],
      scriptSrc: [`'self'`, '@nonce'],
      styleSrc: [`'self'`, '@nonce'],
    },
    reportOnly: false,
  },
})

export default shieldConfig
Warning

Vite 目前不支持为它在开发期间注入到 DOM 中的 style 标签添加 nonce 属性。这是 Vite 团队正在解决的 已知限制 。在解决之前,你可能需要在开发期间对 styleSrc 使用 'unsafe-inline',然后在生产环境中切换到基于 nonce 的策略。

使用 report-only 模式测试策略

配置错误的 CSP 可能会通过阻止合法资源而破坏你的应用。使用 reportOnly 模式可以在不强制执行的情况下测试你的策略。在此模式下,浏览器会报告违规行为,但不会阻止资源。

config/shield.ts
import { defineConfig } from '@adonisjs/shield'

const shieldConfig = defineConfig({
  csp: {
    enabled: true,
    directives: {
      defaultSrc: [`'self'`],
      reportUri: ['/csp-report'],
    },
    reportOnly: true,
  },
})

export default shieldConfig

创建一个端点来收集违规报告。这能帮助你识别在启用强制执行之前忘记加入白名单的资源。

start/routes.ts
import router from '@adonisjs/core/services/router'

router.post('/csp-report', async ({ request, logger }) => {
  const report = request.input('csp-report')
  logger.warn({ report }, 'CSP violation detected')
})

一旦你确认你的策略没有阻止合法资源,将 reportOnly 设置为 false 以启用强制执行。

CSP 配置参考

OptionTypeDescription
enabledboolean开启或关闭 CSP。
directivesobjectCSP 指令,定义每种资源类型允许的来源。
reportOnlybooleantrue 时,会报告违规行为但不阻止它们。用于测试策略。

HSTS(HTTP 严格传输安全)

当用户在没有 https:// 的情况下输入你的域名时,浏览器会先通过不安全的 HTTP 连接,然后才重定向到 HTTPS。这个短暂的窗口允许攻击者通过中间人攻击拦截初始请求,可能降级连接或窃取敏感数据。

HSTS 告诉浏览器始终为你的域名使用 HTTPS,即使用户输入 http:// 或点击纯 HTTP 链接。在收到 HSTS 头之后,浏览器会在指定的时长内自动将所有请求升级为 HTTPS,从而消除不安全的重定向窗口。

config/shield.ts
import { defineConfig } from '@adonisjs/shield'

const shieldConfig = defineConfig({
  hsts: {
    enabled: true,
    maxAge: '180 days',
    includeSubDomains: true,
  },
})

export default shieldConfig

maxAge 选项告诉浏览器应记住这个只使用 HTTPS 的策略多长时间。includeSubDomains 选项将此保护扩展到所有子域名,防止攻击者利用不安全的子域名来危害你的主域名。

Warning

只有在确认 HTTPS 在你的整个域名和所有子域名上都正常工作后,才启用 HSTS。一旦浏览器缓存了 HSTS 策略,在 maxAge 的持续时间内,它们将拒绝通过 HTTP 连接。如果你的 HTTPS 配置中断,在修复之前或缓存策略过期之前,用户将无法访问你的站点。

在测试期间,从较短的 maxAge(如 1 day)开始,然后一旦你对你的 HTTPS 设置充满信心,再将其增加到 180 days 或更长。

HSTS 配置参考

OptionTypeDescription
enabledboolean开启或关闭 HSTS。
maxAgenumberstring浏览器应记住只使用 HTTPS 策略的时长。接受以秒为单位的数字,或像 '180 days' 这样的时间表达式。
includeSubDomainsbooleantrue 时,将只使用 HTTPS 的策略应用于所有子域名。

X-Frame-Options(点击劫持防护)

点击劫持攻击会将你的站点嵌入到恶意页面上一个不可见的 iframe 中。攻击者叠加欺骗性内容,诱使用户点击他们隐藏站点上的按钮。用户可能以为自己点击的是「播放视频」按钮,但实际上点击的是你应用中的「删除账户」。

X-Frame-Options 响应头可防止你的页面被嵌入到其他站点的框架中。

config/shield.ts
import { defineConfig } from '@adonisjs/shield'

const shieldConfig = defineConfig({
  xFrame: {
    enabled: true,
    action: 'DENY',
  },
})

export default shieldConfig

DENY 操作会阻止所有框架嵌入。如果你需要在自己的域名上将站点嵌入框架(例如用于管理面板预览),请改用 SAMEORIGIN

config/shield.ts
import { defineConfig } from '@adonisjs/shield'

const shieldConfig = defineConfig({
  xFrame: {
    enabled: true,
    action: 'SAMEORIGIN',
  },
})

export default shieldConfig

要允许一个特定的外部域名将你的内容嵌入框架,请使用 ALLOW-FROM 并带上域名。

config/shield.ts
import { defineConfig } from '@adonisjs/shield'

const shieldConfig = defineConfig({
  xFrame: {
    enabled: true,
    action: 'ALLOW-FROM',
    domain: 'https://trusted-partner.com',
  },
})

export default shieldConfig
Tip

如果你已经配置了 CSP,可以使用 frame-ancestors 指令来替代 X-Frame-Options。CSP 指令提供了更大的灵活性,包括支持多个域名。在使用 frame-ancestors 时,你可以禁用 xFrame 守卫,以避免冗余的响应头。

X-Frame 配置参考

OptionTypeDescription
enabledboolean开启或关闭 X-Frame 防护。
actionstring框架策略:'DENY''SAMEORIGIN''ALLOW-FROM'
domainstringaction'ALLOW-FROM' 时必填。被允许将你的内容嵌入框架的域名。

内容类型嗅探防护

当服务器没有正确指定内容类型时,浏览器会试图通过猜测来提供帮助。如果你的服务器意外地以错误的内容类型提供了一个用户上传的文件,浏览器可能会「嗅探」该内容,并将其作为脚本执行。攻击者可以上传一个看起来像图片但包含 JavaScript 的文件,而浏览器可能会执行它。

X-Content-Type-Options: nosniff 响应头告诉浏览器信任 Content-Type 响应头,并永远不要猜测。这可以防止内容类型混淆攻击。

config/shield.ts
import { defineConfig } from '@adonisjs/shield'

const shieldConfig = defineConfig({
  contentTypeSniffing: {
    enabled: true,
  },
})

export default shieldConfig

该守卫没有额外的配置选项。启用后,Shield 会向所有响应添加 X-Content-Type-Options: nosniff 响应头。

另请参阅