はじめに
会社のとある勉強会で、MDN Web Docs を読む機会があり、レスポンスヘッダには多くの種類があることを学びました(cf. https://developer.mozilla.org/ja/docs/Web/HTTP/Headers )。これまで、CORSぐらいしか知らなかったのですが、セキュリティ的に設定が推奨されるものもありそうだったので、まとめてみました
なお、コードは以下のリポジトリで公開しています。
結論
フロントエンドの設定は
http {
server {
# フロントエンドの設定
location / {
...
# connect-src: フロントエンドでバックエンドのAPIサーバーを利用するために許可する
# style-src: Vue.jsを利用する場合はselfだけでは効かないので、unsafe-inlineも許可する(ここはより適切な設定ができるかも)
add_header Content-Security-Policy "
default-src 'self';
connect-src 'self' http://localhost:8080;
style-src 'self' 'unsafe-inline';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
";
add_header Cross-Origin-Opener-Policy "same-origin";
add_header Cross-Origin-Resource-Policy "same-origin";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "DENY";
add_header X-Permitted-Cross-Domain-Policies "none";
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()";
add_header Cross-Origin-Embedder-Policy "require-corp";
proxy_hide_header X-Powered-By;
...
}
}
}
バックエンド(FastAPI)の設定は
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel
app = FastAPI()
headers = {
'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none';",
'Cross-Origin-Resource-Policy': 'same-origin',
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'X-Powered-By': "",
}
@app.get("/todos")
def read_todos() -> JSONResponse:
...
return JSONResponse(content=content, headers=headers)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8080, reload=True)
デフォルトのレスポンスヘッダー
まず最初に、何も設定していない(バックエンドのCORSのみ設定しないと通信できないので、設定しています)状態で、リクエストを送ってみて、レスポンスヘッダを確認します
この状態のコードをタグで置いています
https://github.com/eycjur/api_sample/tree/minimum
アプリは単純なTODOアプリで、フロントエンドはTypeScript(Vue.js)、バックエンドはPython(FastAPI)で作成しています。
動作としては、フロントエンドのURLにアクセスすると、フロントエンドのコードを取得します。初期データとして適当なTODOをバックエンドに持たせており、フロントエンドからそのデータを取得も行います。さらに、画面からTODOを追加することもでき、バックエンドにPOSTリクエストを送信し、成功したらTODOが追加されます。
フロントエンド(Vue.js)
<script setup lang="ts">
import { ref } from 'vue'
const backendApiUrl = "http://localhost:8080"
const newTodo = ref("")
const todos = ref(Array<string>())
const getTodos = async () => {
const response = await fetch(`${backendApiUrl}/todos`)
todos.value = await response.json()
}
getTodos()
const addTodo = async () => {
if (newTodo.value.trim() === "") return
const response = await fetch(`${backendApiUrl}/todos`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
todo: newTodo.value
}),
})
if (!response.ok) {
console.error("Failed to add todo")
return
}
todos.value.push(newTodo.value)
newTodo.value = ""
}
</script>
<template>
<h1>ToDos</h1>
<div v-for="todo in todos">
<span>{{ todo }}</span>
</div>
<div>
<input type="text" v-model="newTodo" />
<button @click="addTodo">Add</button>
</div>
</template>
nginx.conf
worker_processes auto;
events {
worker_connections 1024;
}
http {
server {
listen 80;
server_name localhost;
# フロントエンドの設定
location / {
proxy_pass http://localhost:5173;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
バックエンド(FastAPI)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
app = FastAPI()
todos = [
"sample todo 1",
"sample todo 2",
"sample todo 3",
]
class TodoBody(BaseModel):
todo: str
@app.get("/todos")
def read_todos() -> list[str]:
return todos
@app.post("/todos")
def create_todos(body: TodoBody) -> dict[str, str]:
todos.append(body.todo)
return {"message": "Todo created successfully!"}
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8080, reload=True)
まず、最初にフロントエンドのコードを取得する通信のレスポンスヘッダーは最低限のもののみになっていそうです。
同様にバックエンドからの通信もCORSで設定したもののみが設定されています
セキュリティ上設定した方が良いレスポンスヘッダー一覧
いくつかの記事を調べて、出てきたレッダーの一覧を設定するかどうかの候補とし、各候補について役割と、どのように設定するのかをまとめました。
ライブラリを使えばいいという話もありますが、全てのフレームワークに対応するものがあるわけではない&勉強も兼ねて調べています
まず、レスポンスヘッダーの候補は、 OWASP Secure Headers Projectのベストプラクティス に記載されているものや、
レスポンスヘッダを追加してセキュリティを高めてくれるライブラリ(Helmet, Secweb)に実装されているもの、Vue.jsのセキュリティのベストプラクティスに関するブログ、ChatGPTに聞いた結果から洗い出しました。
ヘッダ名 | OWASP | Helmet | Secweb | Vue.jsに関するブログ | ChatGPTに聞いた結果 |
---|---|---|---|---|---|
1.Content-Security-Policy | 〇 | 〇 | 〇 | 〇 | 〇 |
2.Cross-Origin-Opener-Policy | 〇 | 〇 | 〇 | × | × |
3.Cross-Origin-Resource-Policy | 〇 | 〇 | 〇 | × | 〇 |
4.Origin-Agent-Cluster | × | 〇 | 〇 | × | × |
5.Referrer-Policy | 〇 | 〇 | 〇 | 〇 | 〇 |
6.Strict-Transport-Security | 〇 | 〇 | 〇 | 〇 | 〇 |
7.X-Content-Type-Options | 〇 | 〇 | 〇 | 〇 | 〇 |
8.X-DNS-Prefetch-Control | × | 〇 | 〇 | × | × |
9.X-Download-Options | × | 〇 | 〇 | × | × |
10.X-Frame-Options | 〇 | 〇 | 〇 | 〇 | 〇 |
11.X-Permitted-Cross-Domain-Policies | 〇 | 〇 | 〇 | × | × |
12.X-Powered-By | × | 〇 | × | × | × |
13.X-XSS-Protection | × | 〇 | 〇 | × | 〇 |
14.Permissions-Policy | 〇 | × | × | × | 〇 |
15.Expect-CT | × | × | × | × | 〇 |
16.Clear-Site-Data | 〇 | × | 〇 | 〇 | × |
17.HTTP Strict Transport Security(HSTS) for WebSockets | × | × | 〇 | × | × |
18.Cross-Origin-Embedder-Policy | 〇 | × | 〇 | × | × |
19.Cache-Control | 〇 | × | 〇 | × | × |
以下、それぞれの候補について、役割とどのように設定すべきかを考えていきます
1. Content-Security-Policy (CSP)
役割: 指定されたソースからのみコンテンツを読み込むようにブラウザに指示することで、クロスサイトスクリプティング攻撃(XSS)防止します。
フロントエンド:デフォルトでは、自サイト(self)からのリソース読み込みのみを許可し、connect-srcで自サイトおよびバックエンドのAPIサーバーを許可する。style-srcはVue.jsを利用する場合はselfだけでは効かないので、unsafe-inlineも許可する(ここはより適切な設定ができるかもしれないです)
http {
server {
listen 80;
server_name localhost;
# フロントエンドの設定
location / {
add_header Content-Security-Policy "
default-src 'self';
connect-src 'self' http://localhost:8080;
style-src 'self' 'unsafe-inline';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
";
...
バックエンド:APIのみの利用であれば基本的に指定しても効果はないとのことですが、実装しても害ではないとのことです。( https://stackoverflow.com/questions/45630376/shall-i-use-the-content-security-policy-http-header-for-a-backend-api )
headers = {
'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none';",
}
# 全てのエンドポイントのレスポンスに対して、指定したヘッダーを付与してください
# 以下は一例です
@app.get("/todos")
def read_todos() -> JSONResponse:
return JSONResponse(content=todos, headers=headers)
なお、本記事で利用しているCSPは導入コストの観点から、Level2を利用していますが、多くのブラウザではLevel3に対応しているので、Level3を利用することが望ましいようです。
2. Cross-Origin-Opener-Policy
役割: ページが他のページによって開かれた際に、元のページにアクセスすることを制限し、セキュリティを向上させます。
フロントエンド:他のサイトを開くことを想定しない場合はsame-originを指定します。ただし、OAuthや支払いなどの処理を行う場合は、same-origin-allow-popupsのような設定にする必要があります。
http {
server {
listen 80;
server_name localhost;
# フロントエンドの設定
location / {
add_header Cross-Origin-Opener-Policy "same-origin";
...
バックエンド:APIのみの利用であれば指定は不要です
3. Cross-Origin-Resource-Policy
役割: 他のオリジンからリソースへのアクセスを制限する。
あくまで、Webブラウザにおける保護機能であり、レスポンスボディを書き換えているわけではないので、Webブラウザ以外からはレスポンスボディを確認できてしまう点に注意
フロントエンド:他のリソースからのアクセスを想定しない場合はsame-originで設定しましょう
http {
server {
listen 80;
server_name localhost;
# フロントエンドの設定
location / {
add_header Cross-Origin-Resource-Policy "same-origin";
...
バックエンド:APIのみの利用であれば指定は不要です
4. Origin-Agent-Cluster
役割: オリジンベースのプロセス分離を有効にします。
実装有無: この機能は実験的機能であり、FirefoxやSafariでは利用できません。ブラウザは要求を尊重する必要がないという記述もあることから、設定は必須ではありません。
5. Referrer-Policy
役割: リファラーヘッダで送信される際の情報量を制御します。あるサイト上で機密情報を含むGETリクエストを送信した(クエリパラメータに機密情報が含まれている状態)の後に、そのサイト内にある外部リンクを踏むとリファラーヘッダとして機密情報が漏洩してしまいます
実装有無: 主要ブラウザの設定はstrict-origin-when-cross-originがデフォルトです(参考: https://web.dev/articles/referrer-best-practices?hl=ja )。なお、セキュリティ的にはno-referrer
が推奨されていることが多いですが、副作用もある(cf. https://blog.jxck.io/entries/2024-05-07/referrer-policy.html )ので各自で判断してください
6. Strict-Transport-Security (HSTS)
役割: HTTPで接続した場合、ブラウザに対してHTTPS接続を強制するように指示します。
フロントエンド、バックエンドともに実装した方が良いでしょう。なお、31536000は1年間という意味です。また、alwaysをつけることで、200以外のステータスの際にもヘッダを付与します
http {
server {
listen 80;
server_name localhost;
# フロントエンドの設定
location / {
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
...
headers = {
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
}
7. X-Content-Type-Options
役割: ブラウザがサーバーから指定されたMIMEタイプに従うようにします。
フロントエンド:設定しておく方が良いと思われます。
http {
server {
listen 80;
server_name localhost;
# フロントエンドの設定
location / {
add_header X-Content-Type-Options "nosniff";
...
バックエンド:cssやスクリプトを配信しないので不要です
8. X-DNS-Prefetch-Control
役割: ブラウザのDNSプリフェッチ(ブラウザがリンク先のドメインのDNSレコードを事前に解決することで、リンクをクリックした際の応答速度を向上させる技術)を制御します。
実装有無: サイトの高速化の要件に応じて設定してください
9. X-Download-Options
役割: ファイルのダウンロード時の挙動を制御するために使用されます。特に、ダウンロードされたファイルが自動的に実行されるのを防ぎます。(主にInternet Explorerの脆弱性対策)
実装有無:IEが非推奨になった現在では指定は不要だと思われます。
10. X-Frame-Options
役割: ページがフレームに表示されることを防ぎ、クリックジャッキング攻撃を防止します。
フロントエンド:設定した方が良いでしょう
http {
server {
listen 80;
server_name localhost;
# フロントエンドの設定
location / {
add_header X-Frame-Options "DENY";
...
バックエンド:APIのみの利用であれば指定は不要です
11. X-Permitted-Cross-Domain-Policies
役割: Adobe製品(Flash、PDF、Adobe Acrobatなど)でのクロスドメインポリシーを制御します。
フロントエンド:Adobe製品を利用しない場合は禁止して問題ありません
http {
server {
listen 80;
server_name localhost;
# フロントエンドの設定
location / {
add_header X-Permitted-Cross-Domain-Policies "none";
...
12. X-Powered-By
役割: Webサーバーやフレームワークの情報が露出していると、脆弱性が発覚した場合に、攻撃されてしまうため、隠すことが望ましいです。
フロントエンド、バックエンド:フレームワーク側でデフォルトで付与するかどうかが決まっています。ここでは、付与するフレームワークを使う場合を考慮して、設定例を記載します。(Vue.jsもFastAPIも付与しないので、本来は設定不要です。)
http {
server {
listen 80;
server_name localhost;
# フロントエンドの設定
location / {
proxy_hide_header X-Powered-By;
...
headers = {
'X-Powered-By': "",
}
13. X-XSS-Protection
役割: クロスサイトスクリプティング(XSS)フィルタを有効にしますが、非推奨の機能です。
実装有無: 非推奨の機能で、Content-Security-Policyでカバーされるので、実装する必要はありません。
14. Permissions-Policy
役割: ウェブサイトやiframeの要素がブラウザの機能(例:カメラ、マイク、ジオロケーション)を利用できるかどうかを制御します。
フロントエンド: セキュリティとプライバシーの観点から、利用しない機能は明示的に制御することがよさそうです。
http {
server {
listen 80;
server_name localhost;
# フロントエンドの設定
location / {
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()";
...
バックエンド:APIのみの利用であれば指定は不要です
15. Expect-CT
役割: 証明書透明性の要件を定義し、不正な証明書の検出を支援します。
実装有無: 非推奨の機能なので、実装不要です。(日本語版のMDN Web Docsでは特に記載はありませんが、英語版では非推奨とされています: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expect-CT )
16. Clear-Site-Data
役割: サイトのデータ(キャッシュ、クッキー、ストレージなど)をクリアするようブラウザに指示します。
実装有無: ユーザーのログアウト時など、必要に応じて実装してください
17. HTTP Strict Transport Security(HSTS) for WebSockets
役割: WebSocket接続に対してHTTPS接続を強制します。
設定場所: WebSocketsを利用する場合は、Strict-Transport-Securityと同様に設定してください。
18. Cross-Origin-Embedder-Policy
役割: 外部オリジンからのリソースを、読み込むかどうかを制限します。
フロントエンド:CORPまたはCORSの設定だけでは不十分らしい( https://web.dev/articles/why-coop-coep?hl=ja )ので、設定する方がよさそうです。
http {
server {
listen 80;
server_name localhost;
# フロントエンドの設定
location / {
add_header Cross-Origin-Embedder-Policy "require-corp";
...
バックエンド:APIのみの利用であれば指定は不要です
19. Cache-Control
役割: ブラウザのキャッシュ動作を制御します。
実装有無: キャッシュの利用方法に応じて設定してください。
最終的なレスポンスヘッダー
以上の設定を行うと、次のようなレスポンスヘッダーになりました。
また、実装は次のようになります
フロントエンド(Vue.js)
<script setup lang="ts">
import { ref } from 'vue'
const backendApiUrl = "http://localhost:8080"
const newTodo = ref("")
const todos = ref(Array<string>())
const getTodos = async () => {
const response = await fetch(`${backendApiUrl}/todos`)
todos.value = await response.json()
}
getTodos()
const addTodo = async () => {
if (newTodo.value.trim() === "") return
const response = await fetch(`${backendApiUrl}/todos`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
todo: newTodo.value
}),
})
if (!response.ok) {
console.error("Failed to add todo")
return
}
todos.value.push(newTodo.value)
newTodo.value = ""
}
</script>
<template>
<h1>ToDos</h1>
<div v-for="todo in todos">
<span>{{ todo }}</span>
</div>
<div>
<input type="text" v-model="newTodo" />
<button @click="addTodo">Add</button>
</div>
</template>
nginx.conf
worker_processes auto;
events {
worker_connections 1024;
}
http {
server {
listen 80;
server_name localhost;
# フロントエンドの設定
location / {
# connect-src: フロントエンドでバックエンドのAPIサーバーを利用するために許可する
# style-src: Vue.jsを利用する場合はselfだけでは効かないので、unsafe-inlineも許可する(ここはより適切な設定ができるかも)
add_header Content-Security-Policy "
default-src 'self';
connect-src 'self' http://localhost:8080;
style-src 'self' 'unsafe-inline';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
";
add_header Cross-Origin-Opener-Policy "same-origin";
add_header Cross-Origin-Resource-Policy "same-origin";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "DENY";
add_header X-Permitted-Cross-Domain-Policies "none";
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()";
add_header Cross-Origin-Embedder-Policy "require-corp";
proxy_hide_header X-Powered-By;
proxy_pass http://localhost:5173;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
バックエンド(FastAPI)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel
app = FastAPI()
todos = [
"sample todo 1",
"sample todo 2",
"sample todo 3",
]
headers = {
'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none';",
'Cross-Origin-Resource-Policy': 'same-origin',
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'X-Powered-By': "",
}
class TodoBody(BaseModel):
todo: str
@app.get("/todos")
def read_todos() -> JSONResponse:
return JSONResponse(content=todos, headers=headers)
@app.post("/todos")
def create_todos(body: TodoBody) -> JSONResponse:
todos.append(body.todo)
return JSONResponse(content={"message": "Todo created successfully!"}, headers=headers)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost"],
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8080, reload=True)
ここまで読んでいただきありがとうございました。お疲れさまでした。
参考
- https://developer.mozilla.org/en-US/
- https://www.w3.org/TR/CSP3/
- https://qiita.com/yuria-n/items/c50a1bc0ba51f6e33215#%E5%8F%82%E8%80%83url
- https://fastapi.tiangolo.com/advanced/response-headers/
(本文中で直接示したものを除く)