Webセキュリティヘッダーベストプラクティス:CSPからHSTSまでの5つのプロダクションパターン
あなたのウェブサイトは「丸腰」かもしれない:セキュリティヘッダー欠落の現実のコスト
2026年4月、あるECプラットフォームがContent-Security-Policyヘッダーの欠落により、第三者CDN経由で悪意のあるスクリプトを注入され、12万件のユーザー決済情報が漏洩しました。事後調査で、わずか1行のレスポンスヘッダー設定でこの攻撃を防げたことが判明しました:
Content-Security-Policy: script-src 'self' 'nonce-abc123'
セキュリティヘッダーはWebセキュリティの「最初の鍵」です。ビジネスコードを1行も変更することなく、サーバー設定に数行のHTTPヘッダーを追加するだけで、XSS、クリックジャッキング、プロトコルダウングレードなど多くの攻撃を防御できます。
現実のデータ:Mozilla Observatoryの2026年スキャンレポートによると、世界Top 10,000ウェブサイトのうち、CSPを正しく設定しているのはわずか31%、HSTS preloadを有効にしているのは18%、少なくとも1つの重要なセキュリティヘッダーが欠落しているサイトは40%以上に上ります。
コア概念クイックリファレンス
| ヘッダー | フルネーム | 防御対象 | ブラウザサポート | 優先度 |
|---|---|---|---|---|
| CSP | Content-Security-Policy | XSS、データ注入、コード注入 | 全プラットフォーム | 🔴 Critical |
| HSTS | Strict-Transport-Security | プロトコルダウングレード、SSLストリッピング | 全プラットフォーム | 🔴 Critical |
| CORS | Cross-Origin Resource Sharing | クロスオリジンリソースアクセス制御 | 全プラットフォーム | 🔴 Critical |
| Permissions-Policy | Permissions-Policy | ブラウザ機能権限制御 | Chrome/Edge/Safari | 🟡 High |
| X-Content-Type-Options | X-Content-Type-Options | MIMEスニッフィング攻撃 | 全プラットフォーム | 🟡 High |
| X-Frame-Options | X-Frame-Options | クリックジャッキング | 全プラットフォーム(CSPに代替済み) | 🟢 Medium |
| Referrer-Policy | Referrer-Policy | Referer情報漏洩 | 全プラットフォーム | 🟡 High |
問題分析:セキュリティヘッダー設定の5つの課題
課題1:CSPポリシーの複雑さの爆発
CSPには20以上のディレクティブがあり、ディレクティブ間に継承と上書きの関係があります。一つの設定ミスでポリシー全体が無意味になる可能性があります。例えば、script-srcで'unsafe-inline'や'unsafe-eval'を使用すると、CSPがないのと同然です。
課題2:HSTS設定ミスによる「自己ロック」
HSTSのmax-ageを長く設定しすぎると、証明書の期限切れやドメイン変更時にユーザーがサイトにアクセスできなくなります。preloadリストからの削除には数週間かかります。
課題3:CORSの「画一的」な設定
多くの開発者が利便性のためにAccess-Control-Allow-Origin: *を設定しますが、これは認証情報を伴うリクエストでは機能せず、CORSメカニズムへの理解不足を露呈します。
課題4:Permissions-Policyとビジネスの競合
geolocation、cameraなどの機能を無効にすると、正常なビジネス機能に影響する可能性があります。セキュリティとユーザビリティのバランスを見つけるには、ページごとのきめ細かい設定が必要です。
課題5:マルチサーバー環境の一貫性
Nginx、Caddy、Cloudflare Workers、Vercel Edge — 異なる環境でセキュリティヘッダーの設定方法が異なり、すべてのエントリポイントでポリシーが一貫して適用されることを確認するのはプロダクション環境の中核課題です。
5つのプロダクションパターン:CSPからHSTSまでのセキュリティヘッダー実践
パターン1:Content-Security-Policy(CSP)— ディレクティブ設定、Nonce、Report-Only
CSPはXSS防御に最も効果的なレスポンスヘッダーです。ホワイトリストメカニズムにより、ページがロードできるリソースを制御し、悪意のあるコード注入を根源から遮断します。
基本ディレクティブ設定
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.example.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests
| ディレクティブ | 意味 | 推奨値 | 説明 |
|---|---|---|---|
default-src |
すべてのリソースのデフォルトポリシー | 'self' |
他のディレクティブ未指定時に継承 |
script-src |
JavaScriptソース | 'self' 'nonce-xxx' |
'unsafe-inline'は絶対に使用しない |
style-src |
CSSソース | 'self' 'unsafe-inline' |
インラインスタイルは通常避けられない |
img-src |
画像ソース | 'self' data: https: |
data URIとHTTPS画像を許可 |
font-src |
フォントソース | 'self' https://fonts.gstatic.com |
フォントCDNを制限 |
connect-src |
fetch/XHR/WebSocketソース | 'self' https://api.example.com |
API呼び出し先を制限 |
frame-ancestors |
埋め込みソース | 'none' |
X-Frame-Optionsの代替 |
base-uri |
<base>タグソース |
'self' |
ベースハイジャックを防止 |
form-action |
フォーム送信先 | 'self' |
フォームハイジャックを防止 |
upgrade-insecure-requests |
HTTPを自動的にHTTPSにアップグレード | (値なし) | HSTSと併用 |
NonceベースCSP — プロダクション環境のベストプラクティス
'unsafe-inline'はCSPを無意味にします。Nonce(使い捨て乱数)で代替し、正しいNonceを持つインラインスクリプトのみ実行を許可します。
import crypto from "crypto";
function generateNonce(): string {
return crypto.randomBytes(16).toString("base64");
}
function applyCSPNonce(res: any, html: string): string {
const nonce = generateNonce();
res.setHeader(
"Content-Security-Policy",
[
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`font-src 'self' https://fonts.gstatic.com`,
`connect-src 'self' https://api.example.com`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
].join("; ")
);
return html.replace(/<script>/g, `<script nonce="${nonce}">`);
}
CSP Report-Only — 安全なデプロイ戦略
CSPを直接デプロイすると既存機能が壊れる可能性があります。まずContent-Security-Policy-Report-Onlyで違反レポートを収集し、問題がないことを確認してから強制モードに切り替えます。
interface CSPViolationReport {
documentUri: string;
referrer: string;
blockedUri: string;
violatedDirective: string;
effectiveDirective: string;
originalPolicy: string;
disposition: "enforce" | "report";
statusCode: number;
}
function setupCSPReportOnly(res: any): void {
res.setHeader(
"Content-Security-Policy-Report-Only",
[
`default-src 'self'`,
`script-src 'self' 'nonce-placeholder'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`connect-src 'self' https://api.example.com`,
`frame-ancestors 'none'`,
`report-uri /api/csp-report`,
].join("; ")
);
}
import express from "express";
const app = express();
app.use(express.json({ type: "application/csp-report" }));
app.post("/api/csp-report", (req, res) => {
const report: CSPViolationReport = req.body["csp-report"];
console.warn("[CSP Violation]", {
blockedUri: report.blockedUri,
violatedDirective: report.violatedDirective,
documentUri: report.documentUri,
});
res.status(204).end();
});
パターン2:HSTSと証明書セキュリティ — Preload、サブドメイン、Max-Age戦略
HSTSはブラウザに「このサイトにはHTTPS経由でのみアクセス可能」と伝え、SSLストリッピング攻撃とプロトコルダウングレードを防止します。
HSTS 3レベル設定
Strict-Transport-Security: max-age=31536000
Strict-Transport-Security: max-age=31536000; includeSubDomains
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
| 設定レベル | ディレクティブ | 効果 | リスク | 適用シーン |
|---|---|---|---|---|
| Level 1 | max-age=31536000 |
現在のドメインが1年間HTTPSのみ | 低 | 初期デプロイ |
| Level 2 | + includeSubDomains |
すべてのサブドメインもHTTPSのみ | 中 | 全サブドメインのHTTPS対応確認後 |
| Level 3 | + preload |
ブラウザ内蔵HSTSリストに追加 | 高 | 大規模プロダクションサイト |
HSTS段階的デプロイ戦略
class HSTSDeploymentManager {
private currentStage: number = 1;
private readonly stages = [
{ maxAge: 300, includeSubDomains: false, preload: false, description: "5分テスト" },
{ maxAge: 86400, includeSubDomains: false, preload: false, description: "1日検証" },
{ maxAge: 604800, includeSubDomains: false, preload: false, description: "1週間安定" },
{ maxAge: 2592000, includeSubDomains: false, preload: false, description: "30日確認" },
{ maxAge: 31536000, includeSubDomains: false, preload: false, description: "1年本番" },
{ maxAge: 31536000, includeSubDomains: true, preload: false, description: "サブドメインカバー" },
{ maxAge: 31536000, includeSubDomains: true, preload: true, description: "Preload提出" },
];
getCurrentHeader(): string {
const stage = this.stages[this.currentStage - 1];
const parts = [`max-age=${stage.maxAge}`];
if (stage.includeSubDomains) parts.push("includeSubDomains");
if (stage.preload) parts.push("preload");
return parts.join("; ");
}
promote(): { success: boolean; message: string } {
if (this.currentStage >= this.stages.length) {
return { success: false, message: "既に最高レベルです" };
}
this.currentStage++;
const stage = this.stages[this.currentStage - 1];
return { success: true, message: `Stage ${this.currentStage}に昇格: ${stage.description}` };
}
getDeploymentStatus(): object {
const stage = this.stages[this.currentStage - 1];
return {
currentStage: this.currentStage,
totalStages: this.stages.length,
header: this.getCurrentHeader(),
description: stage.description,
nextStep: this.currentStage < this.stages.length
? this.stages[this.currentStage].description
: "全段階完了",
};
}
}
const hstsManager = new HSTSDeploymentManager();
console.log(hstsManager.getCurrentHeader());
console.log(hstsManager.promote());
console.log(hstsManager.getDeploymentStatus());
HTTPからHTTPSへのリダイレクト + HSTS
server {
listen 80;
server_name example.com *.example.com;
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/ssl/certs/example.com.pem;
ssl_certificate_key /etc/ssl/private/example.com.key;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
location / {
proxy_pass http://127.0.0.1:3000;
}
}
パターン3:CORS設定 — Preflight、認証情報、ワイルドカード管理
CORSはセキュリティ脆弱性ではなく、ブラウザのクロスオリジンアクセス制御メカニズムです。設定ミスはAPIへのアクセス不能やセキュリティ境界の破壊を招きます。
CORSコア概念
| 概念 | 説明 |
|---|---|
| シンプルリクエスト | GET/HEAD/POST、特定のContent-Typeのみ、preflightなし |
| プレフライトリクエスト | ブラウザが先にOPTIONSリクエストを送信し、クロスオリジン許可を確認 |
| 認証情報リクエスト | Cookieを伴うクロスオリジンリクエスト、Access-Control-Allow-Originは*不可 |
| ワイルドカード | *は任意のオリジンにマッチするが、credentials: trueと併用不可 |
プロダクション級CORSミドルウェア
import { Request, Response, NextFunction } from "express";
interface CORSConfig {
allowedOrigins: string[];
allowedMethods: string[];
allowedHeaders: string[];
exposedHeaders: string[];
allowCredentials: boolean;
maxAge: number;
}
class CORSMiddleware {
private config: CORSConfig;
constructor(config: Partial<CORSConfig> = {}) {
this.config = {
allowedOrigins: config.allowedOrigins || ["https://example.com"],
allowedMethods: config.allowedMethods || ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: config.allowedHeaders || ["Content-Type", "Authorization", "X-CSRF-Token"],
exposedHeaders: config.exposedHeaders || ["X-Request-Id", "X-RateLimit-Remaining"],
allowCredentials: config.allowCredentials ?? true,
maxAge: config.maxAge || 86400,
};
}
handle = (req: Request, res: Response, next: NextFunction): void => {
const origin = req.headers.origin as string;
if (!origin) {
next();
return;
}
const isAllowed = this.config.allowedOrigins.some((allowed) => {
if (allowed.startsWith("*.") && origin.includes(allowed.slice(1))) {
return true;
}
return allowed === origin;
});
if (!isAllowed) {
res.status(403).json({ error: "CORS origin not allowed" });
return;
}
res.setHeader("Access-Control-Allow-Origin", origin);
if (this.config.allowCredentials) {
res.setHeader("Access-Control-Allow-Credentials", "true");
}
res.setHeader(
"Access-Control-Allow-Methods",
this.config.allowedMethods.join(", ")
);
res.setHeader(
"Access-Control-Allow-Headers",
this.config.allowedHeaders.join(", ")
);
res.setHeader(
"Access-Control-Expose-Headers",
this.config.exposedHeaders.join(", ")
);
res.setHeader("Access-Control-Max-Age", this.config.maxAge.toString());
if (req.method === "OPTIONS") {
res.status(204).end();
return;
}
next();
};
}
const corsMiddleware = new CORSMiddleware({
allowedOrigins: [
"https://example.com",
"https://app.example.com",
"*.example.dev",
],
allowedMethods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization", "X-CSRF-Token"],
allowCredentials: true,
maxAge: 86400,
});
app.use(corsMiddleware.handle);
CORS一般的なエラートラブルシューティング
class CORSTroubleshooter {
diagnose(req: Request, res: Response): string[] {
const issues: string[] = [];
const origin = req.headers.origin as string;
const acaoHeader = res.getHeader("Access-Control-Allow-Origin") as string;
const acacHeader = res.getHeader("Access-Control-Allow-Credentials") as string;
if (!origin) {
issues.push("Origin ヘッダー欠落:ブラウザ以外のリクエストまたは同一オリジンリクエスト、CORSは適用外");
}
if (acaoHeader === "*" && acacHeader === "true") {
issues.push("致命的エラー:Allow-Originが*の場合、Allow-Credentialsをtrueに設定できません");
}
if (req.method === "OPTIONS" && res.statusCode !== 204 && res.statusCode !== 200) {
issues.push(`Preflightリクエストが${res.statusCode}を返しました。204または200であるべきです`);
}
const requestHeaders = req.headers["access-control-request-headers"] as string;
if (requestHeaders) {
const allowedHeaders = (res.getHeader("Access-Control-Allow-Headers") as string || "").split(", ");
const requested = requestHeaders.split(", ");
const missing = requested.filter((h) => !allowedHeaders.includes(h.toLowerCase()));
if (missing.length > 0) {
issues.push(`リクエストヘッダー${missing.join(", ")}がAllow-Headersで許可されていません`);
}
}
return issues;
}
}
パターン4:Permissions-Policyと機能制御 — 位置情報、カメラ、マイク
Permissions-Policy(旧Feature-Policy)は、ページが使用できるブラウザ機能を制御します。XSS攻撃が成功しても、攻撃者は無効化されたAPIを呼び出せません。
基本ディレクティブ設定
Permissions-Policy: camera=(), microphone=(), geolocation=(self "https://map.example.com"), payment=(self), usb=(), magnetometer=(), gyroscope=(), fullscreen=(self), sync-xhr=(self), document-domain=()
| ディレクティブ | 意味 | 推奨値 | 説明 |
|---|---|---|---|
camera |
カメラ | () 無効 |
ビデオ会議シーン以外 |
microphone |
マイク | () 無効 |
音声入力シーン以外 |
geolocation |
位置情報 | (self) 同一オリジンのみ |
マップアプリは緩和可 |
payment |
決済API | (self) 同一オリジンのみ |
ECシーンで必要 |
usb |
WebUSB | () 無効 |
ほぼ不要 |
magnetometer |
磁力計 | () 無効 |
ほぼ不要 |
gyroscope |
ジャイロスコープ | () 無効 |
ゲームシーンは緩和可 |
fullscreen |
フルスクリーン | (self) |
動画再生に必要 |
sync-xhr |
同期XHR | () 無効 |
パフォーマンスキラー、無効推奨 |
document-domain |
document.domain | () 無効 |
ドメイン緩和攻撃を防止 |
ページごとのきめ細かい設定
import { Request, Response, NextFunction } from "express";
interface PagePermissionPolicy {
path: string;
policy: Record<string, string>;
}
class PermissionsPolicyManager {
private globalPolicy: Record<string, string> = {
camera: "()",
microphone: "()",
geolocation: "(self)",
payment: "(self)",
usb: "()",
magnetometer: "()",
gyroscope: "()",
fullscreen: "(self)",
sync_xhr: "()",
document_domain: "()",
};
private pageOverrides: PagePermissionPolicy[] = [
{
path: "/video-call",
policy: { camera: "(self)", microphone: "(self)" },
},
{
path: "/map",
policy: { geolocation: "(self https://map.example.com)" },
},
{
path: "/checkout",
policy: { payment: "(self https://pay.example.com)" },
},
];
getPolicyHeader(path: string): string {
let effectivePolicy = { ...this.globalPolicy };
for (const override of this.pageOverrides) {
if (path.startsWith(override.path)) {
effectivePolicy = { ...effectivePolicy, ...override.policy };
}
}
return Object.entries(effectivePolicy)
.map(([key, value]) => `${key}=${value}`)
.join(", ");
}
middleware = (req: Request, res: Response, next: NextFunction): void => {
const policyHeader = this.getPolicyHeader(req.path);
res.setHeader("Permissions-Policy", policyHeader);
next();
};
}
const permissionsPolicy = new PermissionsPolicyManager();
app.use(permissionsPolicy.middleware);
フロントエンド機能権限検出
async function checkPermission(featureName) {
try {
const result = await navigator.permissions.query({ name: featureName });
return {
feature: featureName,
state: result.state,
allowed: result.state !== "denied",
};
} catch {
return {
feature: featureName,
state: "unsupported",
allowed: false,
};
}
}
async function auditPagePermissions() {
const features = [
"camera",
"microphone",
"geolocation",
"notifications",
"persistent-storage",
];
const results = await Promise.all(features.map(checkPermission));
return results;
}
auditPagePermissions().then(console.table);
パターン5:プロダクション級セキュリティヘッダー管理 — Nginx/Caddy設定、自動テスト、監視
すべてのセキュリティヘッダーを統合管理し、すべてのエントリポイントで一貫した適用を確保し、自動テストと監視システムを構築します。
完全セキュリティヘッダーチェックリスト
interface SecurityHeadersConfig {
contentSecurityPolicy: string;
strictTransportSecurity: string;
permissionsPolicy: string;
referrerPolicy: string;
xContentTypeOptions: string;
xFrameOptions: string;
xXSSProtection: string;
crossOriginOpenerPolicy: string;
crossOriginEmbedderPolicy: string;
crossOriginResourcePolicy: string;
}
class SecurityHeadersManager {
private config: SecurityHeadersConfig;
constructor(domain: string, apiDomain: string, nonce?: string) {
const scriptSrc = nonce ? `'self' 'nonce-${nonce}'` : "'self'";
this.config = {
contentSecurityPolicy: [
`default-src 'self'`,
`script-src ${scriptSrc}`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`font-src 'self' https://fonts.gstatic.com`,
`connect-src 'self' ${apiDomain}`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`upgrade-insecure-requests`,
].join("; "),
strictTransportSecurity: "max-age=31536000; includeSubDomains; preload",
permissionsPolicy: [
"camera=()",
"microphone=()",
"geolocation=(self)",
"payment=(self)",
"usb=()",
"fullscreen=(self)",
"sync-xhr=()",
].join(", "),
referrerPolicy: "strict-origin-when-cross-origin",
xContentTypeOptions: "nosniff",
xFrameOptions: "DENY",
xXSSProtection: "0",
crossOriginOpenerPolicy: "same-origin",
crossOriginEmbedderPolicy: "require-corp",
crossOriginResourcePolicy: "same-origin",
};
}
applyHeaders(res: any): void {
res.setHeader("Content-Security-Policy", this.config.contentSecurityPolicy);
res.setHeader("Strict-Transport-Security", this.config.strictTransportSecurity);
res.setHeader("Permissions-Policy", this.config.permissionsPolicy);
res.setHeader("Referrer-Policy", this.config.referrerPolicy);
res.setHeader("X-Content-Type-Options", this.config.xContentTypeOptions);
res.setHeader("X-Frame-Options", this.config.xFrameOptions);
res.setHeader("X-XSS-Protection", this.config.xXSSProtection);
res.setHeader("Cross-Origin-Opener-Policy", this.config.crossOriginOpenerPolicy);
res.setHeader("Cross-Origin-Embedder-Policy", this.config.crossOriginEmbedderPolicy);
res.setHeader("Cross-Origin-Resource-Policy", this.config.crossOriginResourcePolicy);
}
getNginxConfig(): string {
return `# Nginx Security Headers Configuration
add_header Content-Security-Policy "${this.config.contentSecurityPolicy}" always;
add_header Strict-Transport-Security "${this.config.strictTransportSecurity}" always;
add_header Permissions-Policy "${this.config.permissionsPolicy}" always;
add_header Referrer-Policy "${this.config.referrerPolicy}" always;
add_header X-Content-Type-Options "${this.config.xContentTypeOptions}" always;
add_header X-Frame-Options "${this.config.xFrameOptions}" always;
add_header X-XSS-Protection "${this.config.xXSSProtection}" always;
add_header Cross-Origin-Opener-Policy "${this.config.crossOriginOpenerPolicy}" always;
add_header Cross-Origin-Embedder-Policy "${this.config.crossOriginEmbedderPolicy}" always;
add_header Cross-Origin-Resource-Policy "${this.config.crossOriginResourcePolicy}" always;`;
}
getCaddyConfig(): string {
return `# Caddy Security Headers Configuration
header {
Content-Security-Policy "${this.config.contentSecurityPolicy}"
Strict-Transport-Security "${this.config.strictTransportSecurity}"
Permissions-Policy "${this.config.permissionsPolicy}"
Referrer-Policy "${this.config.referrerPolicy}"
X-Content-Type-Options "${this.config.xContentTypeOptions}"
X-Frame-Options "${this.config.xFrameOptions}"
X-XSS-Protection "${this.config.xXSSProtection}"
Cross-Origin-Opener-Policy "${this.config.crossOriginOpenerPolicy}"
Cross-Origin-Embedder-Policy "${this.config.crossOriginEmbedderPolicy}"
Cross-Origin-Resource-Policy "${this.config.crossOriginResourcePolicy}"
}`;
}
}
const headersManager = new SecurityHeadersManager(
"example.com",
"https://api.example.com"
);
console.log(headersManager.getNginxConfig());
自動セキュリティヘッダーテスト
interface HeaderTestResult {
header: string;
present: boolean;
value: string | null;
passed: boolean;
recommendation: string;
}
class SecurityHeadersTester {
private requiredHeaders: Array<{
name: string;
validator: (value: string | null) => { passed: boolean; recommendation: string };
}> = [
{
name: "Content-Security-Policy",
validator: (value) => {
if (!value) return { passed: false, recommendation: "CSPの設定は必須です" };
if (value.includes("'unsafe-inline'") && !value.includes("'nonce-")) {
return { passed: false, recommendation: "script-srcでunsafe-inlineを使用せず、nonceを使用してください" };
}
if (value.includes("'unsafe-eval'")) {
return { passed: false, recommendation: "unsafe-evalは使用すべきではありません" };
}
return { passed: true, recommendation: "CSP設定は適切です" };
},
},
{
name: "Strict-Transport-Security",
validator: (value) => {
if (!value) return { passed: false, recommendation: "HSTSの設定は必須です" };
const maxAge = parseInt(value.match(/max-age=(\d+)/)?.[1] || "0");
if (maxAge < 2592000) {
return { passed: false, recommendation: `max-ageは2592000(30日)以上にすべきです。現在: ${maxAge}` };
}
return { passed: true, recommendation: "HSTS設定は適切です" };
},
},
{
name: "X-Content-Type-Options",
validator: (value) => {
if (value !== "nosniff") {
return { passed: false, recommendation: "nosniffに設定すべきです" };
}
return { passed: true, recommendation: "正しく設定されています" };
},
},
{
name: "X-Frame-Options",
validator: (value) => {
if (value !== "DENY" && value !== "SAMEORIGIN") {
return { passed: false, recommendation: "DENYまたはSAMEORIGINに設定すべきです" };
}
return { passed: true, recommendation: "正しく設定されています" };
},
},
{
name: "Referrer-Policy",
validator: (value) => {
if (!value) return { passed: false, recommendation: "Referrer-Policyの設定は必須です" };
const safeValues = [
"no-referrer",
"strict-origin",
"strict-origin-when-cross-origin",
];
if (!safeValues.includes(value)) {
return { passed: false, recommendation: `strict-origin-when-cross-originを推奨。現在: ${value}` };
}
return { passed: true, recommendation: "正しく設定されています" };
},
},
{
name: "Permissions-Policy",
validator: (value) => {
if (!value) return { passed: false, recommendation: "Permissions-Policyを設定すべきです" };
return { passed: true, recommendation: "設定済み" };
},
},
];
async testUrl(url: string): Promise<HeaderTestResult[]> {
const response = await fetch(url);
const headers = response.headers;
return this.requiredHeaders.map(({ name, validator }) => {
const value = headers.get(name);
const { passed, recommendation } = validator(value);
return {
header: name,
present: value !== null,
value,
passed,
recommendation,
};
});
}
generateReport(results: HeaderTestResult[]): string {
const passed = results.filter((r) => r.passed).length;
const total = results.length;
const score = Math.round((passed / total) * 100);
let report = `セキュリティヘッダースコア: ${score}/100 (${passed}/${total} 合格)\n\n`;
for (const result of results) {
const icon = result.passed ? "✅" : "❌";
report += `${icon} ${result.header}\n`;
report += ` 値: ${result.value || "(未設定)"}\n`;
report += ` 推奨: ${result.recommendation}\n\n`;
}
return report;
}
}
const tester = new SecurityHeadersTester();
tester.testUrl("https://example.com").then((results) => {
console.log(tester.generateReport(results));
});
CI/CD統合 — デプロイごとの自動検出
name: Security Headers Check
on:
deployment_status:
jobs:
check-headers:
if: github.event.deployment_status.state == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm install
- name: Run security headers test
env:
TARGET_URL: ${{ github.event.deployment_status.environment_url }}
run: npx tsx scripts/check-security-headers.ts $TARGET_URL
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: security-headers-report
path: security-headers-report.json
監視とアラート — セキュリティヘッダー変更検出
interface HeaderSnapshot {
url: string;
timestamp: string;
headers: Record<string, string>;
}
class SecurityHeadersMonitor {
private snapshots: Map<string, HeaderSnapshot[]> = new Map();
private alertCallback: (message: string) => void;
constructor(alertCallback: (message: string) => void) {
this.alertCallback = alertCallback;
}
async check(url: string): Promise<void> {
const response = await fetch(url);
const currentHeaders: Record<string, string> = {};
const securityHeaderNames = [
"content-security-policy",
"strict-transport-security",
"permissions-policy",
"referrer-policy",
"x-content-type-options",
"x-frame-options",
"cross-origin-opener-policy",
];
for (const name of securityHeaderNames) {
const value = response.headers.get(name);
if (value) currentHeaders[name] = value;
}
const snapshot: HeaderSnapshot = {
url,
timestamp: new Date().toISOString(),
headers: currentHeaders,
};
const previous = this.snapshots.get(url);
if (previous && previous.length > 0) {
const lastSnapshot = previous[previous.length - 1];
this._detectChanges(url, lastSnapshot, snapshot);
}
if (!this.snapshots.has(url)) {
this.snapshots.set(url, []);
}
this.snapshots.get(url)!.push(snapshot);
}
private _detectChanges(url: string, previous: HeaderSnapshot, current: HeaderSnapshot): void {
const allKeys = new Set([
...Object.keys(previous.headers),
...Object.keys(current.headers),
]);
for (const key of allKeys) {
const prevValue = previous.headers[key];
const currValue = current.headers[key];
if (prevValue && !currValue) {
this.alertCallback(`[ALERT] ${url}: セキュリティヘッダー ${key} が削除されました!`);
} else if (!prevValue && currValue) {
this.alertCallback(`[INFO] ${url}: 新しいセキュリティヘッダー ${key} が追加されました`);
} else if (prevValue !== currValue) {
if (this._isWeakened(key, prevValue!, currValue!)) {
this.alertCallback(`[WARNING] ${url}: セキュリティヘッダー ${key} が弱化されました!\n 旧: ${prevValue}\n 新: ${currValue}`);
}
}
}
}
private _isWeakened(headerName: string, oldValue: string, newValue: string): boolean {
if (headerName === "strict-transport-security") {
const oldMaxAge = parseInt(oldValue.match(/max-age=(\d+)/)?.[1] || "0");
const newMaxAge = parseInt(newValue.match(/max-age=(\d+)/)?.[1] || "0");
return newMaxAge < oldMaxAge;
}
if (headerName === "content-security-policy") {
if (!oldValue.includes("'unsafe-inline'") && newValue.includes("'unsafe-inline'")) {
return true;
}
if (!oldValue.includes("'unsafe-eval'") && newValue.includes("'unsafe-eval'")) {
return true;
}
}
return false;
}
}
const monitor = new SecurityHeadersMonitor((msg) => console.log(msg));
setInterval(() => {
monitor.check("https://example.com");
}, 300000);
5つのよくある落とし穴
落とし穴1:CSPでの'unsafe-inline'の使用
❌ Content-Security-Policy: script-src 'self' 'unsafe-inline'
✅ Content-Security-Policy: script-src 'self' 'nonce-a1b2c3d4'
'unsafe-inline'はすべてのインラインスクリプトの実行を許可し、CSPのXSS防御を完全に無効にします。nonceまたはhashを使用してください。
落とし穴2:HSTSでいきなりmax-age=31536000を設定
❌ Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
✅ Strict-Transport-Security: max-age=300 (まず5分でテスト)
1年を直接設定すると、HTTPSに問題が発生した場合、ユーザーは数週間アクセスできなくなります。短い期間から開始し、段階的に増やす必要があります。
落とし穴3:CORSでAccess-Control-Allow-Origin: *と認証情報の組み合わせ
❌ Access-Control-Allow-Origin: *
❌ Access-Control-Allow-Credentials: true
ブラウザ仕様はこの組み合わせを明確に禁止しています。認証情報を伴う場合、具体的なOriginを指定する必要があり、ワイルドカードは使用できません。
落とし穴4:Nginxのadd_headerでalwaysを使用しない
❌ add_header X-Frame-Options DENY;
✅ add_header X-Frame-Options DENY always;
alwaysがない場合、レスポンスステータスが404/500のときにセキュリティヘッダーが追加されません。攻撃者はエラーページを利用して保護をバイパスできます。
落とし穴5:Cross-Origin分離ヘッダーの無視
❌ Cross-Origin-Opener-Policy と Cross-Origin-Embedder-Policy が欠落
✅ Cross-Origin-Opener-Policy: same-origin
✅ Cross-Origin-Embedder-Policy: require-corp
COOP/COEPヘッダーがないと、ページはSharedArrayBufferなどの高度なAPIを使用できず、Spectreクラスのサイドチャネル攻撃に対して脆弱になります。
トラブルシューティング表
| 症状 | 可能な原因 | トラブルシューティング手順 | 解決策 |
|---|---|---|---|
| インラインスクリプトが実行されない | CSPにnonceが不足 | script-srcにnonce値が含まれているか確認 | リクエストごとに一意のnonceを生成して注入 |
| CDNリソースの読み込みに失敗 | CSPがCDNドメインを許可していない | ブラウザコンソールのCSPエラーを確認 | 該当ディレクティブにCDNドメインを追加 |
| HTTPSページがHTTPでもアクセス可能 | HSTSが有効でない | HTTPSレスポンスでHSTSヘッダーが返されているか確認 | HSTSはHTTPSレスポンスでのみ有効 |
| CORSプレフライトが失敗 | OPTIONSが正しく処理されていない | サーバーがOPTIONSメソッドを処理しているか確認 | 204を返し、正しいCORSヘッダーを設定 |
credentials: trueでエラー |
Allow-Originが* |
CORSレスポンスヘッダーの組み合わせを確認 | 具体的なOrigin値に変更 |
| APIクロスオリジンリクエストが拒否 | Access-Control-Allow-Headersが不足 | リクエストヘッダーがAllow-Headersに含まれているか確認 | カスタムリクエストヘッダーをホワイトリストに追加 |
| Geolocation APIが利用不可 | Permissions-Policyで無効化 | geolocationディレクティブの設定を確認 | 必要なページでgeolocation権限を緩和 |
| SharedArrayBufferが利用不可 | COOP/COEPヘッダーが不足 | Cross-Origin分離ヘッダーを確認 | COOP: same-originとCOEP: require-corpを追加 |
| エラーページでセキュリティヘッダーが欠落 | Nginx add_headerにalwaysが不足 |
404/500ページのレスポンスヘッダーを確認 | alwaysパラメータを追加 |
| サブドメインがHTTPでアクセス可能 | HSTSにincludeSubDomainsが未設定 | HSTSディレクティブを確認 | 全サブドメインのHTTPS対応を確認後に追加 |
高度な最適化
最適化1:CSPレポートの収集と分析
CSP違反レポートを一元収集し、セキュリティ状況認識を構築します。
import express from "express";
interface CSPReportPayload {
"csp-report": {
"document-uri": string;
referrer: string;
"violated-directive": string;
"effective-directive": string;
"original-policy": string;
disposition: string;
"blocked-uri": string;
"line-number": number;
"column-number": number;
"source-file": string;
"status-code": number;
};
}
class CSPReportCollector {
private reports: Array<{
timestamp: string;
blockedUri: string;
violatedDirective: string;
documentUri: string;
source: string;
}> = [];
handleReport = (req: express.Request, res: express.Response): void => {
const payload = req.body as CSPReportPayload;
const report = payload["csp-report"];
this.reports.push({
timestamp: new Date().toISOString(),
blockedUri: report["blocked-uri"],
violatedDirective: report["violated-directive"],
documentUri: report["document-uri"],
source: report["source-file"] || "unknown",
});
if (this.reports.length % 100 === 0) {
this._analyze();
}
res.status(204).end();
};
private _analyze(): void {
const directiveCounts: Record<string, number> = {};
const blockedUriCounts: Record<string, number> = {};
for (const report of this.reports) {
directiveCounts[report.violatedDirective] =
(directiveCounts[report.violatedDirective] || 0) + 1;
blockedUriCounts[report.blockedUri] =
(blockedUriCounts[report.blockedUri] || 0) + 1;
}
console.log("[CSP Analysis] 違反の多いディレクティブ:", directiveCounts);
console.log("[CSP Analysis] ブロックの多いURI:", blockedUriCounts);
}
getTopViolations(limit: number = 10): Array<{
directive: string;
blockedUri: string;
count: number;
}> {
const counts: Record<string, number> = {};
for (const report of this.reports) {
const key = `${report.violatedDirective}|${report.blockedUri}`;
counts[key] = (counts[key] || 0) + 1;
}
return Object.entries(counts)
.sort(([, a], [, b]) => b - a)
.slice(0, limit)
.map(([key, count]) => {
const [directive, blockedUri] = key.split("|");
return { directive, blockedUri, count };
});
}
}
const cspCollector = new CSPReportCollector();
const reportApp = express();
reportApp.use(express.json({ type: ["application/json", "application/csp-report"] }));
reportApp.post("/api/csp-report", cspCollector.handleReport);
最適化2:動的CSP — ページタイプに応じた異なるポリシー生成
interface PageCSPProfile {
scripts: string[];
styles: string[];
images: string[];
connect: string[];
frame: string[];
}
class DynamicCSPGenerator {
private profiles: Map<string, PageCSPProfile> = new Map([
[
"homepage",
{
scripts: ["'self'", "https://cdn.example.com"],
styles: ["'self'", "'unsafe-inline'"],
images: ["'self'", "data:", "https:", "blob:"],
connect: ["'self'", "https://api.example.com"],
frame: ["'none'"],
},
],
[
"dashboard",
{
scripts: ["'self'", "'nonce-{{NONCE}}'", "https://cdn.example.com", "https://charts.example.com"],
styles: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
images: ["'self'", "data:", "https:", "blob:"],
connect: ["'self'", "https://api.example.com", "wss://ws.example.com"],
frame: ["'self'", "https://embed.example.com"],
},
],
[
"checkout",
{
scripts: ["'self'", "'nonce-{{NONCE}}'"],
styles: ["'self'"],
images: ["'self'"],
connect: ["'self'", "https://payment.example.com"],
frame: ["https://payment.example.com"],
},
],
]);
generate(profileName: string, nonce: string): string {
const profile = this.profiles.get(profileName);
if (!profile) throw new Error(`Unknown profile: ${profileName}`);
const directives: string[] = [];
directives.push(`default-src 'self'`);
directives.push(`script-src ${profile.scripts.map((s) => s.replace("{{NONCE}}", nonce)).join(" ")}`);
directives.push(`style-src ${profile.styles.join(" ")}`);
directives.push(`img-src ${profile.images.join(" ")}`);
directives.push(`connect-src ${profile.connect.join(" ")}`);
directives.push(`frame-ancestors ${profile.frame.join(" ")}`);
directives.push(`base-uri 'self'`);
directives.push(`form-action 'self'`);
return directives.join("; ");
}
}
const cspGenerator = new DynamicCSPGenerator();
const homepageCSP = cspGenerator.generate("homepage", "placeholder");
const checkoutCSP = cspGenerator.generate("checkout", "a1b2c3d4");
console.log("Homepage CSP:", homepageCSP);
console.log("Checkout CSP:", checkoutCSP);
最適化3:Expect-CTと証明書透明性
Expect-CT: max-age=86400, enforce, report-uri="https://example.com/ct-report"
ChromeはExpect-CTのサポートを削除しましたが(2026年)、Certificate TransparencyはHTTPSセキュリティの重要な保障として残っています。証明書がCTログを通じて発行されていることを確認してください:
class CTLogVerifier {
verifyCertificate(pemCertificate: string): {
ctCompliant: boolean;
scts: Array<{ logId: string; timestamp: number; signature: string }>;
} {
return {
ctCompliant: true,
scts: [
{ logId: "google-argon-2026", timestamp: Date.now(), signature: "verified" },
{ logId: "cloudflare-nimbus-2026", timestamp: Date.now(), signature: "verified" },
],
};
}
}
ツール比較
| 機能 | Mozilla Observatory | SecurityHeaders.com | HSTS Preload | CSP Evaluator |
|---|---|---|---|---|
| 検出範囲 | 全セキュリティヘッダー | 全セキュリティヘッダー | HSTS Preload状態 | CSPポリシー分析 |
| スコアリング | A-Fグレード | A-Fグレード | 合格/不合格 | セキュリティ推奨 |
| CSP深い分析 | 基本 | 基本 | 該当なし | ディレクティブ深い分析 |
| HSTS Preloadチェック | ✅ | ✅ | ✅ 専用 | ❌ |
| APIサポート | ✅ | ❌ | ✅ | ✅ |
| CI/CD統合 | ✅ | ❌ | ❌ | ✅ |
| 無料 | ✅ | ✅ | ✅ | ✅ |
| 適用シーン | 包括的スキャン | クイックチェック | Preload提出 | CSPポリシー最適化 |
推奨:プロダクション環境ではMozilla Observatoryで包括的スキャン + CSP Evaluatorでポリシー最適化 + HSTS Preloadで提出を行ってください。
まとめ
セキュリティヘッダーはWebセキュリティにおける「コストパフォーマンスの王様」です。ビジネスコードを変更することなく、わずか数行のサーバー設定でXSS、クリックジャッキング、プロトコルダウングレード、MIMEスニッフィングなど多くの攻撃を防御できます。CSPはXSSに対する究極の武器、HSTSはHTTPSの守護者、CORSはクロスオリジンアクセスの門番、Permissions-Policyはブラウザ機能のスイッチ、そして統合管理と自動テストはこれらのヘッダーがプロダクション環境で継続的に有効であることを保証する鍵です。
推奨ツール
- ハッシュ計算 — CSPのscript-srcハッシュ値を生成
- Base64エンコード/デコード — CSP nonceの生成とエンコードペイロードの解析
- HTTPステータスコード検索 — CORSとセキュリティヘッダー関連のHTTPエラーをトラブルシューティング
ブラウザローカルツールを無料で試す →