OWASP Top 10 とは、Webアプリケーションで特に重大なセキュリティリスクを10項目にまとめた、OWASP(Open Worldwide Application Security Project)の文書です。 最新版の OWASP Top 10:2025 は、2025年11月に公開され、2021年版から「ソフトウェアサプライチェーンの不備」と「例外条件の不適切な処理」が新設され、SSRF が「アクセス制御の不備」に統合されました。

この記事では、公式サイト(top10.owasp.org)で確認した10項目の正式名称と2021→2025の変更表を示したうえで、項目ごとに「起きること」「脆弱なコード → 修正コード」「確認方法」を表とコードでまとめます。アクセス制御の抜け(他人の注文IDを指定できる)、SSRF の宛先制限、セキュリティヘッダー、pip-audit / npm audit、スタックトレースが返る例外処理、パスワードがログに残る問題は、FastAPI と Express で実際に動かして「直す前」「直した後」の応答を掲載しました。最後にコピペで使える開発者向けチェックリストとレビュー観点を置いています。

OWASP Top 10 2025 の早見表

日本語名は本記事の訳です。「起きること」は各項目の代表例で、詳細は本文で扱います。

順位 正式名称(2025) 日本語の意味 起きること(代表例) 主な対策 2021からの変化
A01 Broken Access Control アクセス制御の不備 他人のIDを指定すると他人のデータが見える/内部URLを取得させられる(SSRF) 既定は拒否・所有者チェック・宛先の許可リスト 1位を維持。SSRF を統合
A02 Security Misconfiguration セキュリティ設定の不備 debug のまま公開、セキュリティヘッダーなし、不要な管理画面 ヘッダー付与、不要機能の無効化、設定の自動検証 5位 → 2位
A03 Software Supply Chain Failures ソフトウェアサプライチェーンの不備 既知の脆弱性がある依存パッケージ、改ざんされたビルド pip-audit / npm audit、ロックファイル、署名・SBOM 新設(旧 A06 を拡張)
A04 Cryptographic Failures 暗号化の失敗 推測できる乱数のトークン、平文保存、古いアルゴリズム secrets、TLS 1.2 以上、Argon2 などの適応型ハッシュ 2位 → 4位
A05 Injection インジェクション SQL・OSコマンド・テンプレートに入力が混ざる パラメータ化、引数のリスト渡し、出力エスケープ 3位 → 5位
A06 Insecure Design 安全でない設計 数量にマイナスが通る、フローの抜けを突かれる 脅威モデリング、上限・下限などの妥当性チェック 4位 → 6位
A07 Authentication Failures 認証の失敗 総当たりが止まらない、固定されたセッションID 試行回数制限、MFA、漏えい済みパスワードの照合 7位を維持(名称変更)
A08 Software or Data Integrity Failures ソフトウェアまたはデータの整合性の不備 署名のない更新・データを信頼、安全でないデシリアライズ 署名検証、ロックファイル、SRI 8位を維持(名称変更)
A09 Security Logging & Alerting Failures セキュリティログと警告の不備 失敗が記録されない、ログに機密が残る、ログ偽装 構造化ログ、機密のマスク、通知の設計 9位を維持(Monitoring → Alerting)
A10 Mishandling of Exceptional Conditions 例外条件の不適切な処理 スタックトレースが利用者に返る、失敗時に許可側へ倒れる 入力検証、一般化した応答、fail closed 新設

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「OWASP Top 10 2025|全10項目を脆弱コードと修正例で解説」

2021 → 2025 で何が変わったか

2025年版は、2021年版の10項目のうち9項目を引き継ぎ、SSRF を A01 に統合して空いた枠に「ソフトウェアサプライチェーンの不備」と「例外条件の不適切な処理」が入りました。 公式の Introduction ページの記述をもとに整理すると次の通りです。

2021 2025 変化の種類
A01 Broken Access Control A01 Broken Access Control 維持(SSRF を含む40件のCWEに拡大)
A02 Cryptographic Failures A04 Cryptographic Failures 2つ下がる
A03 Injection A05 Injection 2つ下がる
A04 Insecure Design A06 Insecure Design 2つ下がる
A05 Security Misconfiguration A02 Security Misconfiguration 3つ上がる
A06 Vulnerable and Outdated Components A03 Software Supply Chain Failures 拡張して新設(依存だけでなくビルド・配布・開発ツールまで対象)
A07 Identification and Authentication Failures A07 Authentication Failures 維持・名称変更
A08 Software and Data Integrity Failures A08 Software or Data Integrity Failures 維持・名称変更(and → or)
A09 Security Logging and Monitoring Failures A09 Security Logging & Alerting Failures 維持・名称変更(警告の重視)
A10 Server-Side Request Forgery (A01 に統合) 統合
— A10 Mishandling of Exceptional Conditions 新設(24件のCWE。エラー処理・fail open など)

データの取り方も変わりました。 2025年版は約280万件のアプリケーションのテスト結果と、2021年の約400件から増えた589件のCWEをもとに8項目を選び、残る2項目(A06 と A10)はコミュニティ調査から選ばれています。順位は「発生率(脆弱性を持つアプリの割合)」で決まるため、2位に上がった設定不備のように、規模が大きくなるほど起きやすいものが上位に来る構造です。

この記事の検証環境

「直す前(app_before)」と「直した後(app_after)」を同じファイルに書き、別ポートで起動して curl で比べました。コードは各項目のセクションに抜粋しています。

項目 内容
言語・FW Python 3.11.15 / FastAPI 0.142.2(Starlette 1.0.0)/ uvicorn 0.46.0 / httpx 0.28.1
Node 側 Node.js 22.22.2 / npm 10.9.7 / Express 5.2.1 / helmet 8.3.0
監査ツール pip-audit 2.10.1 / npm audit(npm 10.9.7)
起動 uvicorn app:app_before --port 7550 と uvicorn app:app_after --port 7551 --no-server-header
データ 架空の注文データ(1001 は alice、1002 は bob が所有)。APIトークンは tok-alice / tok-bob

A01 アクセス制御の不備:所有者チェックと SSRF の宛先制限

A01 は「ログインしているか」だけを確かめ、「その人がそのデータを扱ってよいか」を確かめていない状態です。 2025年版では40件のCWEが対象で、テストしたアプリの発生率は最大20.15%。他人のIDを指定するだけで見えてしまう「IDOR(CWE-639)」、ディレクトリトラバーサル(CWE-22)、そして統合された SSRF(CWE-918)が含まれます。

脆弱なコード → 修正コード(他人の注文を取得できる)

# 直す前:ログインは確認しているが「自分の注文か」を確認していない
@app_before.get("/orders/{order_id}")
def before_get_order(order_id: int, user: str = Depends(current_user)):
    order = ORDERS.get(order_id)
    if not order:
        raise HTTPException(status_code=404, detail="not found")
    return order

# 直した後:所有者を確認する。他人の注文も 404 にして存在の有無を推測させない
@app_after.get("/orders/{order_id}")
def after_get_order(order_id: int, user: str = Depends(current_user)):
    order = ORDERS.get(order_id)
    if not order or order["owner"] != user:
        raise HTTPException(status_code=404, detail="not found")
    return order

alice のトークンで bob の注文 1002 を取得した結果です。

$ curl -s -i http://127.0.0.1:7550/orders/1002 -H 'Authorization: Bearer tok-alice'   # 直す前
HTTP/1.1 200 OK
{"id":1002,"owner":"bob","item":"ペン","qty":10}

$ curl -s -i http://127.0.0.1:7551/orders/1002 -H 'Authorization: Bearer tok-alice'   # 直した後
HTTP/1.1 404 Not Found
{"detail":"not found"}

$ curl -s http://127.0.0.1:7551/orders/1001 -H 'Authorization: Bearer tok-alice'      # 自分の注文は取れる
{"id":1001,"owner":"alice","item":"ノート","qty":2}

ポイントは、「認証」と「認可」を分けて考え、認可はサーバー側のコードで、レコードの所有者や役割に基づいて判定することです。 一覧取得や更新・削除でも同じチェックを通すため、共通の依存関数(FastAPI なら Depends)にまとめると抜けが減ります。

SSRF:利用者のURLをそのまま取得しない

SSRF は、サーバーに「このURLを取得して」と頼める機能から、社内ネットワークやクラウドのメタデータなど、外からは届かない宛先にアクセスさせられる問題です。 検証では、ポート 7552 に「社内からしか届かない想定」のサービス、7553 に「提携先の公開API」のつもりのサービスを立てました。

# 直す前:利用者が渡した URL をそのまま取得
@app_before.get("/fetch")
def before_fetch(url: str):
    r = httpx.get(url, timeout=3)
    return {"status": r.status_code, "body": r.text[:200]}

# 直した後:取得先が決まっている処理は (ホスト, ポート) の許可リストで絞る。
#           任意の外部 URL を受ける処理(Webhook 登録など)は名前解決した IP が内部でないことを確認する
ALLOWED = {("127.0.0.1", 7553)}     # 検証用。実運用は "api.partner.example" など固定ホスト

def is_internal(ip: str) -> bool:
    a = ipaddress.ip_address(ip)
    return a.is_private or a.is_loopback or a.is_link_local or a.is_reserved or a.is_multicast

def fetch_checked(url: str, allow_any_public: bool) -> dict:
    p = urlparse(url)
    if p.scheme not in ("http", "https"):
        raise HTTPException(status_code=400, detail="scheme not allowed")
    host = p.hostname or ""
    port = p.port or (443 if p.scheme == "https" else 80)
    if not allow_any_public and (host, port) not in ALLOWED:
        raise HTTPException(status_code=400, detail="destination not allowed")
    if allow_any_public:
        try:
            ips = {info[4][0] for info in socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)}
        except socket.gaierror:
            raise HTTPException(status_code=400, detail="host not resolvable")
        if any(is_internal(ip) for ip in ips):
            raise HTTPException(status_code=400, detail="private address not allowed")
    r = httpx.get(url, timeout=3, follow_redirects=False)   # リダイレクトで別宛先へ飛ばない
    return {"status": r.status_code, "body": r.text[:200]}

@app_after.get("/fetch")
def after_fetch(url: str):
    return fetch_checked(url, allow_any_public=False)

@app_after.get("/webhook-test")
def after_webhook_test(url: str):
    return fetch_checked(url, allow_any_public=True)
送った url 直す前 /fetch 直した後 /fetch(許可リスト) 直した後 /webhook-test(内部IP拒否)
http://127.0.0.1:7552/internal/config(社内サービス) 200 {"db_host":"db.internal","admin_flag":true} が返る 400 destination not allowed 400 private address not allowed
http://127.0.0.1:7553/catalog(提携先) 200 200 {"items":["ノート","ペン"]} (内部IPのため 400。実運用では公開IPのみ通る)
file:///etc/hostname 500(httpx が UnsupportedProtocol を送出し、スタックトレースが返った) 400 scheme not allowed 400 scheme not allowed
http://localhost:7552/internal/config 200 400 400(名前解決で 127.0.0.1 と判明)
http://169.254.169.254/latest/meta-data/ (到達しない環境) 400 400 private address not allowed
http://10.0.0.5/ (到達しない環境) 400 400
http://no-such-host.invalid/ 500 400 400 host not resolvable

SSRF 対策の要点は「宛先を利用者に決めさせない」ことです。 取得先が決まっているなら許可リスト、任意のURLを受けるなら「スキーム限定・名前解決後のIP検査・リダイレクト追従の無効化・タイムアウト」を組み合わせ、可能ならネットワーク側でも外向き通信を絞ります。

A01 の確認方法と関連ツール

確認したいこと 方法
他人のIDで取得・更新・削除できないか 2ユーザー分のトークンを用意し、相手のIDを指定するテストを CRUD ごとに書く
権限のないパス(管理画面・内部API)に直接アクセスできないか 未ログイン/一般ユーザーで /admin 系を叩く自動テスト
SSRF URL を受ける機能を洗い出し、127.0.0.1・localhost・内部レンジ・メタデータIPを送る
設計として 「既定は拒否(deny by default)」。公開リソース以外は明示的な許可が必要な構造にする

A02 セキュリティ設定の不備:ヘッダー・debug・不要な画面

A02 は、フレームワークやサーバーの既定値・便利機能をそのまま公開したことで起きる問題で、2021年の5位から2位に上がりました。 公式は「再現可能な堅牢化(hardening)の手順」「不要な機能の削除」「セキュリティヘッダーの送信」「設定の自動検証」を挙げています。

脆弱な設定 → 修正(FastAPI)

# 直す前:debug=True のまま、/docs も公開、セキュリティヘッダーなし
app_before = FastAPI(debug=True)

# 直した後:不要な画面を出さず、全応答にセキュリティヘッダーを付ける
app_after = FastAPI(docs_url=None, redoc_url=None)   # debug は既定の False

SECURITY_HEADERS = {
    "Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'",
    "X-Content-Type-Options": "nosniff",
    "X-Frame-Options": "DENY",
    "Referrer-Policy": "strict-origin-when-cross-origin",
    "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
    "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
    "Cache-Control": "no-store",
}

class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        response = await call_next(request)
        for k, v in SECURITY_HEADERS.items():
            response.headers.setdefault(k, v)
        return response

app_after.add_middleware(SecurityHeadersMiddleware)
$ curl -s -D - -o /dev/null http://127.0.0.1:7550/orders/1001 -H 'Authorization: Bearer tok-alice'   # 直す前
HTTP/1.1 200 OK
date: Wed, 30 Sep 2026 15:08:37 GMT
server: uvicorn
content-length: 54
content-type: application/json

$ curl -s -D - -o /dev/null http://127.0.0.1:7551/orders/1001 -H 'Authorization: Bearer tok-alice'   # 直した後
HTTP/1.1 200 OK
date: Wed, 30 Sep 2026 15:08:37 GMT
content-length: 54
content-type: application/json
content-security-policy: default-src 'self'; frame-ancestors 'none'
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=()
strict-transport-security: max-age=31536000; includeSubDomains
cache-control: no-store

$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:7550/docs   # 直す前は API 仕様画面が公開されている
200
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:7551/docs
404

server: uvicorn は --no-server-header で消えました。ヘッダーは「付いているか」を自動テストで確かめると、フレームワーク更新で既定値が変わっても気づけます。

Express なら helmet

// server.js(Express 5.2.1 / helmet 8.3.0)
import express from "express";
import helmet from "helmet";

const app = express();
app.disable("x-powered-by");            // 既定では X-Powered-By: Express が付く
app.use(helmet());                       // 既定で CSP・HSTS・nosniff などを付ける
app.use(helmet.contentSecurityPolicy({   // directives は既定とマージされる(useDefaults: true)
  directives: { defaultSrc: ["'self'"], frameAncestors: ["'none'"] },
}));
app.get("/orders/:id", (req, res) => res.json({ id: req.params.id }));
app.listen(7554);
$ curl -s -D - -o /dev/null http://127.0.0.1:7555/orders/1   # helmet なし
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8

$ curl -s -D - -o /dev/null http://127.0.0.1:7554/orders/1   # helmet あり(抜粋)
HTTP/1.1 200 OK
Content-Security-Policy: default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'none';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Referrer-Policy: no-referrer
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
確認項目 確認方法・設定
debug/開発用画面が本番で無効か FastAPI(debug=False)(既定)、Django DEBUG=False、Rails production 環境。/docs /redoc /actuator などの公開範囲
セキュリティヘッダー 上記ヘッダーが全応答に付くテスト。CSP の段階導入は既存記事「CSP設定」を参照
不要な機能・サンプル ディレクトリ一覧(nginx autoindex off)、サンプルアプリ、既定アカウント
秘密情報の置き場所 設定ファイルに直書きせず環境変数・秘密管理サービスへ。短命な認証情報を優先
設定の自動検証 本番と同じ構成をコード化(IaC)し、CI で lint・差分確認
XXE(CWE-611) XML パーサーで外部エンティティを無効化(Python defusedxml、Java XMLConstants.FEATURE_SECURE_PROCESSING)

A03 ソフトウェアサプライチェーンの不備:pip-audit と npm audit

A03 は、依存パッケージの既知の脆弱性に加え、ビルド環境・CI/CD・配布経路・開発ツールの改ざんまでを含む、2025年版で新設された項目です。 公式は SBOM の生成、依存の継続的な追跡、信頼できる配布元、署名済みパッケージ、段階的なロールアウトを挙げています。

実行:古い版を固定した requirements.txt を監査

$ cat requirements-old.txt
fastapi==0.109.0
jinja2==3.1.3
requests==2.31.0
python-multipart==0.0.6

$ pip-audit -r requirements-old.txt --desc off
Found 45 known vulnerabilities in 5 packages
Name             Version ID              Fix Versions
---------------- ------- --------------- ------------
fastapi          0.109.0 PYSEC-2024-38   0.109.1
jinja2           3.1.3   PYSEC-2026-1474 3.1.4
jinja2           3.1.3   PYSEC-2026-1475 3.1.5
jinja2           3.1.3   PYSEC-2026-1472 3.1.5
jinja2           3.1.3   PYSEC-2026-1471 3.1.6
requests         2.31.0  PYSEC-2026-1873 2.32.0
requests         2.31.0  PYSEC-2026-1872 2.32.4
requests         2.31.0  PYSEC-2026-2275 2.33.0
python-multipart 0.0.6   PYSEC-2024-38   0.0.7
python-multipart 0.0.6   PYSEC-2026-1851 0.0.18
starlette        0.35.1  PYSEC-2026-1943 0.40.0
(以下略。直接指定していない starlette も、依存として解決されて対象になる)

今回の検証中に、インストールしたばかりの python-multipart 0.0.26 にも修正版が出ていることが分かりました。 更新して再監査した結果です。

$ pip-audit -r requirements-now.txt --desc off     # python-multipart==0.0.26 を含む
Found 8 known vulnerabilities in 1 package
Name             Version ID              Fix Versions
---------------- ------- --------------- ------------
python-multipart 0.0.26  PYSEC-2026-3039 0.0.27
python-multipart 0.0.26  PYSEC-2026-3040 0.0.31
python-multipart 0.0.26  PYSEC-2026-3036 0.0.30
python-multipart 0.0.26  PYSEC-2026-3037 0.0.30

$ pip install -U python-multipart      # 0.0.32 に更新
$ pip-audit -r requirements-fixed.txt --desc off
No known vulnerabilities found

実行:npm audit

$ cat package.json
{ "name": "audit-demo", "private": true,
  "dependencies": { "express": "4.17.1", "lodash": "4.17.20" } }

$ npm install --package-lock-only --ignore-scripts
$ npm audit
# npm audit report

body-parser  <=1.20.5
Severity: high
body-parser vulnerable to denial of service when url encoding is enabled - https://github.com/advisories/GHSA-qwcr-r2fm-qrc7
...
lodash  <=4.17.23
Severity: high
Command Injection in lodash - https://github.com/advisories/GHSA-35jh-r3h4-6jhm
Regular Expression Denial of Service (ReDoS) in lodash - https://github.com/advisories/GHSA-29mw-wpgm-hmr9
...
path-to-regexp  <=0.1.12
Severity: high
...
8 vulnerabilities (3 low, 5 high)

$ npm install --package-lock-only express@4.22.3 lodash@4.18.1
$ npm audit
found 0 vulnerabilities
やること 具体策
既知の脆弱性を継続的に検出 CI で pip-audit / npm audit --audit-level=high を毎回実行。GitHub の Dependabot などで更新PRを自動化
再現可能なインストール ロックファイル(package-lock.json、uv.lock)をコミットし、CI は npm ci / uv sync --locked
配布元を固定 社内レジストリやプロキシ経由に限定。.npmrc / pip.conf で index を固定
CI/CD の改ざん対策 GitHub Actions は uses: owner/action@<commit SHA> で固定。ビルド環境の権限は最小に
成果物の署名・SBOM cyclonedx 系ツールで SBOM を生成し、配布物には署名(Sigstore など)
導入時の確認 メンテナンス状況、ダウンロード数、postinstall スクリプトの有無。npm install --ignore-scripts で確認してから導入

A04 暗号化の失敗:乱数・比較・保存

A04 は「暗号を使っていない」だけでなく、「暗号用でない乱数」「古いアルゴリズム」「鍵の直書き」など、使い方の誤りを含みます。 2025年版で目立つのは乱数まわりの CWE(CWE-331 エントロピー不足、CWE-338 暗号用でない疑似乱数)です。

import random, secrets

# 直す前:random は統計用。seed が分かると同じ列を再現できる
random.seed(20260930)
print(random.choices("abc...789", k=16))   # 実行するたび同じ列
# 直した後:secrets は OS の暗号用乱数を使う
token = secrets.token_urlsafe(32)          # 例: K-wnA7jLu2X3rFZ7cSr0PjXCTKibDkokf1NKdZso5VM
# 秘密値の比較は compare_digest(比較にかかる時間から中身を推測させない)
secrets.compare_digest(expected, given)
[before] random : qwils7x3bz9q5vab
[before] again  : qwils7x3bz9q5vab      ← seed が同じなので同じ値
[after]  token  : K-wnA7jLu2X3rFZ7cSr0PjXCTKibDkokf1NKdZso5VM
[after]  token  : rzO39ZVX6FM7FvOq7LrXBDu8sraBWDfuf9EIHwxYxeU
[after]  compare: True False
場面 推奨 避けたい状態
トークン・セッションID secrets.token_urlsafe(32)、Node crypto.randomBytes(32) random、Math.random()、時刻ベース
パスワード保存 Argon2id / bcrypt など適応型ハッシュ(既存記事「パスワードのハッシュ化」参照) MD5 / SHA-1 / 単純な SHA-256
通信 TLS 1.2 以上、HSTS 平文 HTTP、証明書検証の無効化
データの暗号化 認証付き暗号(AES-GCM、ChaCha20-Poly1305) ECB / CBC 単独、鍵のソース直書き
鍵の管理 環境変数や秘密管理サービス、定期ローテーション リポジトリにコミット

公式は「2030年に向けた耐量子暗号への準備」にも触れています。すぐの作業ではありませんが、アルゴリズムを差し替えられる設計(暗号アジリティ)にしておくと移行が楽になります。

A05 インジェクション:安全な API を使う

A05 は、利用者の入力がコマンドやクエリの「構造」の一部として解釈される問題です。 SQL インジェクション(CWE-89)と XSS(CWE-79)は既存記事で実行例つきで扱っているため、ここでは既存記事にない OS コマンドインジェクションを実行し、種類ごとの対策を表にまとめます。

import re, subprocess
from pathlib import Path

BASE = Path("data")
folder = "uploads; echo INJECTED"   # 利用者から届いた「フォルダ名」

# 直す前:文字列をシェルに渡す(; 以降が別のコマンドとして実行される)
r = subprocess.run(f"du -sh {BASE}/{folder}", shell=True, capture_output=True, text=True)

# 直した後:①入力の形式を確認 ②引数はリストで渡す(shell=False)
def folder_size(name: str) -> str:
    if not re.fullmatch(r"[A-Za-z0-9_\-]{1,64}", name):
        raise ValueError(f"invalid folder name: {name!r}")
    r = subprocess.run(["du", "-sh", str(BASE / name)], capture_output=True, text=True, check=True)
    return r.stdout.split()[0]
[before] returncode: 0
[before] stdout: ['8.0K\tdata/uploads', 'INJECTED']   ← 2つ目のコマンドまで実行された
[after] invalid folder name: 'uploads; echo INJECTED'
[after] uploads = 8.0K
種類 起きること 対策(安全な API)
SQL 文字列連結でクエリの構造が変わる プレースホルダ(cursor.execute(sql, params))、ORM
XSS HTML に入力がそのまま出る テンプレートの自動エスケープ、CSP(既存記事参照)
OS コマンド shell=True で ; && が効く 引数のリスト渡し、許可する文字の検証
テンプレート(SSTI) 利用者の文字列をテンプレート「として」描画 テンプレート本体は固定し、値だけ渡す
LDAP / XPath / NoSQL クエリ文字列に入力が混ざる ライブラリのエスケープ関数、パラメータ化
ログ(CWE-117) 改行入りの入力でログ行を偽装 A09 で扱う(JSON 化・エスケープ)

プロンプトインジェクションは OWASP の別文書(LLM Top 10)の範囲で、既存記事「プロンプトインジェクション対策」で扱っています。

A06 安全でない設計:妥当性チェックと脅威モデリング

A06 は実装のバグではなく、「そもそもそう作ると守れない」設計の問題です。 公式は脅威モデリング、安全な設計パターンのライブラリ化、ユースケースと対になる「ミスユースケース」、各層での妥当性(plausibility)チェックを挙げています。

# 直した後:設計として「数量は1〜100」「品名は50文字まで」と決め、スキーマで強制する
class OrderIn(BaseModel):
    item: str = Field(min_length=1, max_length=50)
    qty: int = Field(gt=0, le=100)

@app_after.post("/orders", status_code=201)
def after_create_order(body: OrderIn, user: str = Depends(current_user)):
    ...
$ curl -s -X POST http://127.0.0.1:7551/orders -H 'Authorization: Bearer tok-alice' \
    -H 'Content-Type: application/json' -d '{"item":"ノート","qty":-5}'
{"detail":[{"type":"greater_than","loc":["body","qty"],"msg":"Input should be greater than 0","input":-5,"ctx":{"gt":0}}]}   # 422
設計時に確認する問い 例
値の上限・下限は決まっているか 数量、金額相当の数値、一覧の件数、文字列長、ファイルサイズ
手順を飛ばせないか 「確認 → 確定」の確定だけを直接呼べないか
誰がどの操作をできるか表になっているか 役割 × 操作のマトリクス(A01 の実装の元になる)
自動化された悪用に耐えるか 登録・投稿・API 呼び出しのレート制限
失敗したらどうなるか 途中で止まったときに「許可側」に倒れない(A10)

A07 認証の失敗:試行回数制限と安全な比較

A07 は「本人確認」の弱さで、総当たり・漏えいしたパスワードの使い回し・セッションIDの扱いが中心です。 公式は MFA、漏えい済みパスワード一覧との照合、失敗回数の制限、サーバー側での高エントロピーなセッションID生成を挙げ、「定期的なパスワード変更の強制は避ける」としています。

# 直した後:失敗回数を制限し、比較は compare_digest。実運用の記録は Redis 等で共有する
FAILS: dict[str, list[float]] = {}
LIMIT, WINDOW = 5, 300

@app_after.post("/login")
def after_login(body: LoginIn, request: Request):
    key = f"{request.client.host}:{body.username}"
    now = time.time()
    recent = [t for t in FAILS.get(key, []) if now - t < WINDOW]
    if len(recent) >= LIMIT:
        raise HTTPException(status_code=429, detail="too many attempts, try later",
                            headers={"Retry-After": str(WINDOW)})
    ok = secrets.compare_digest(PASSWORDS.get(body.username, ""), body.password)   # 本番はハッシュ検証
    if not ok:
        FAILS[key] = recent + [now]
        raise HTTPException(status_code=401, detail="invalid credentials")
    FAILS.pop(key, None)
    return {"ok": True}
$ 誤ったパスワードで 6 回 POST /login
HTTP/1.1 401 Unauthorized
HTTP/1.1 401 Unauthorized
HTTP/1.1 401 Unauthorized
HTTP/1.1 401 Unauthorized
HTTP/1.1 401 Unauthorized
HTTP/1.1 429 Too Many Requests   retry-after: 300
$ 制限中に正しいパスワードで POST /login
{"detail":"too many attempts, try later"} 429
対策 内容
試行回数の制限 IP × ユーザー名で回数を数え、429 と Retry-After。段階的な待ち時間も有効
パスワードの扱い 長さ重視(8文字以上、上限は64以上)、漏えい済み一覧との照合、適応型ハッシュ
MFA 管理者・重要操作から順に導入。TOTP やパスキー
セッション ログイン成功時にIDを再発行(固定化対策)、ログアウトでサーバー側も無効化
エラーメッセージ 「ユーザー名が違う」と「パスワードが違う」を区別しない

JWT の扱い(有効期限、無効化)は既存記事「JWTとは」「JWTでログアウトを実装する方法」で扱っています。

A08 ソフトウェアまたはデータの整合性の不備:署名・ロック・SRI

A08 は「改ざんされていないことを確かめずに、コードやデータを信頼する」問題です。 安全でないデシリアライズ(CWE-502)、署名のない更新の適用(CWE-494)、信頼できない CDN からの読み込みが含まれます。

import hmac, hashlib, json

SECRET = b"server-side-secret-key"   # 環境変数や秘密管理サービスから読む

def sign(data: dict) -> str:
    body = json.dumps(data, sort_keys=True, separators=(",", ":")).encode()
    return hmac.new(SECRET, body, hashlib.sha256).hexdigest()

def verify(data: dict, signature: str) -> bool:
    return hmac.compare_digest(sign(data), signature)
signature: d9d84646f51b823a...
そのまま       : True
内容を書き換え : False     ← status を "shipped" から書き換えたデータは受け付けない
場面 対策
クライアントから戻ってくるデータ(フォームの隠し項目、署名付きCookie) HMAC で署名し、サーバーで検証。秘密鍵はサーバーだけが持つ
シリアライズ形式 pickle.loads や Java の ObjectInputStream に外部入力を渡さず、JSON など「データだけ」の形式にする
外部スクリプトの読み込み <script src integrity="sha384-..." crossorigin="anonymous">(SRI)
依存の取得 ロックファイル+npm ci、パッケージ署名の検証、GitHub Actions のコミット固定
自動更新 更新ファイルの署名検証。検証なしで実行しない
CI/CD 本番デプロイ権限とビルド権限の分離、コードレビュー必須

A09 セキュリティログと警告の不備:機密を書かず、偽装させず、通知する

A09 は「何が起きたか後から分からない」「ログ自体が問題を生む」状態です。 対象の CWE は5件と少なめですが、CWE-532(ログへの機密の書き込み)と CWE-117(ログ偽装)は実装で防げます。

# 直す前:平文パスワードをログに書き、入力の改行もそのまま書く
plain_log.info("login attempt user=%s password=%s", body.username, body.password)

# 直した後:フィールド名で機密をマスクし、JSON で1行に固定する
class JsonFormatter(logging.Formatter):
    SECRET_KEYS = re.compile(r"(password|token|secret|authorization)", re.I)

    def format(self, record):
        payload = {"time": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
                   "level": record.levelname, "event": record.getMessage()}
        for k, v in getattr(record, "fields", {}).items():
            payload[k] = "***" if self.SECRET_KEYS.search(k) else v
        return json.dumps(payload, ensure_ascii=False)   # 改行や制御文字は \n などにエスケープされる

def log_event(event: str, **fields):
    sec_log.info(event, extra={"fields": fields})

log_event("login_failed", user=body.username, ip=request.client.host, password=body.password)

ユーザー名に改行と偽のログ行を含めて送った結果です。

# 直す前のログ(3行目以降は利用者が送った文字列)
INFO before: login attempt user=alice
INFO before: login attempt user=admin password=x
INFO before: admin action approved password=wrong-pass

# 直した後のログ(1行の JSON。改行は \n として残り、password は ***)
{"time": "2026-10-01T00:09:50", "level": "INFO", "event": "login_failed", "user": "alice\nINFO: admin action approved", "ip": "127.0.0.1", "password": "***"}
{"time": "2026-10-01T00:09:51", "level": "INFO", "event": "login_blocked", "user": "bob", "ip": "127.0.0.1"}
記録する 記録しない 警告(アラート)にする
ログイン成功・失敗、アクセス制御の失敗、入力検証の失敗(ユーザー・IP・時刻つき) パスワード、トークン、セッションID、個人情報の本文 短時間の失敗の集中、管理者操作、想定外の例外の急増
管理操作・権限変更・設定変更 秘密鍵、APIキー 監査ログの改変や欠落

ログは「集める」だけでなく、「誰に、どの条件で通知するか」まで決めて初めて A09 の対策になります。 名称が Monitoring から Alerting に変わったのはこの点を強調するためです。

A10 例外条件の不適切な処理:検証・一般化した応答・fail closed

A10 は2025年版で新設された項目で、「想定外の入力や失敗をどう扱うか」がテーマです。 CWE-209(エラーメッセージに機密情報)、CWE-636(失敗時に許可側へ倒れる fail open)、CWE-755(例外の不適切な処理)など24件を含みます。

# 直す前:入力を検証せず int()。debug=True のためスタックトレースがそのまま返る
@app_before.get("/report")
def before_report(count: str):
    return {"total": 100 / int(count)}

# 直した後 ①:型と範囲を宣言し、外れた入力は 422 で返す
@app_after.get("/report")
def after_report(count: Annotated[int, Query(gt=0, le=1000)]):
    return {"total": 100 / count}

# 直した後 ②:想定外の例外は ID 付きの一般化した応答にし、詳細はログにだけ残す
@app_after.exception_handler(Exception)
async def unhandled(request: Request, exc: Exception):
    error_id = uuid.uuid4().hex[:12]
    log_event("unhandled_exception", error_id=error_id, path=request.url.path,
              exc=repr(exc), trace=traceback.format_exc())
    return JSONResponse(status_code=500, content={"error": "internal_error", "error_id": error_id})
$ curl -s -i 'http://127.0.0.1:7550/report?count=abc'      # 直す前
HTTP/1.1 500 Internal Server Error
content-type: text/plain; charset=utf-8
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/dist-packages/starlette/middleware/errors.py", line 164, in __call__
  ...(55行。ライブラリのパスやアプリのファイル名・行番号が含まれる)
  File ".../owasp/app.py", line 74, in before_report
    return {"total": 100 / int(count)}
ValueError: invalid literal for int() with base 10: 'abc'

$ curl -s 'http://127.0.0.1:7551/report?count=abc'         # 直した後 ①
{"detail":[{"type":"int_parsing","loc":["query","count"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"abc"}]}   # 422
$ curl -s 'http://127.0.0.1:7551/report?count=0'
{"detail":[{"type":"greater_than","loc":["query","count"],"msg":"Input should be greater than 0","input":"0","ctx":{"gt":0}}]}   # 422

$ curl -s 'http://127.0.0.1:7551/export'                   # 直した後 ②:ファイルがない想定外の失敗
{"error":"internal_error","error_id":"6cfb5d652f55"}       # 500
# ログ側(1行の JSON。trace にスタックトレース全文が入る)
{"time": "2026-10-01T00:09:58", "level": "INFO", "event": "unhandled_exception", "error_id": "6cfb5d652f55", "path": "/export", "exc": "FileNotFoundError(2, 'No such file or directory')", "trace": "Traceback (most recent call last):\n ..."}

利用者には error_id だけを返し、問い合わせがあればログの同じ ID で追えるようにするのが基本形です。

観点 実装
入力は境界で検証 型・範囲・長さをスキーマで宣言(Pydantic、Zod、Bean Validation)
想定外の例外は一箇所で受ける グローバルな例外ハンドラで一般化した応答。debug は本番で無効
fail closed 認可・署名検証・外部サービスの失敗時は「拒否」に倒す。except: pass で握りつぶさない
途中で止まらない トランザクションはまとめてロールバック。リソース(ファイル・ロック)は with で確実に解放
資源の枯渇 タイムアウト、サイズ上限、レート制限で「例外条件そのもの」を減らす

開発者向けチェックリスト(コピペ用)

プルリクエストのテンプレートや設計レビューにそのまま貼れる形にしました。

項目 チェック内容 確認
A01 取得・更新・削除で所有者または役割を判定している(2ユーザーのテストがある) ☐
A01 外部URLを受ける処理は、許可リストか内部IP検査+リダイレクト無効で守っている ☐
A02 本番で debug・開発用画面・ディレクトリ一覧が無効。セキュリティヘッダーのテストがある ☐
A03 ロックファイルをコミットし、CI で pip-audit / npm audit を実行。Actions はSHA固定 ☐
A04 トークンは secrets / crypto.randomBytes。パスワードは適応型ハッシュ。TLS 1.2 以上 ☐
A05 SQL はプレースホルダ、コマンドはリスト渡し、HTML は自動エスケープ ☐
A06 数量・件数・長さ・サイズの上限下限を決め、スキーマで強制している ☐
A07 ログイン失敗の回数制限、セッションIDの再発行、エラー文で存在を区別しない ☐
A08 外部入力を pickle 等でデシリアライズしない。署名付きデータは検証している。CDN は SRI ☐
A09 ログに機密がない(マスク)。JSON 等で1行に固定。通知条件が決まっている ☐
A10 想定外の例外は一般化した応答+error_id。失敗時は拒否側に倒れる ☐

レビューで見る観点

観点 見つけ方
「IDを受け取って返す」処理に所有者チェックがあるか get(id) の直後に owner や role の比較があるか。共通の依存関数を通っているか
文字列連結でクエリ・コマンド・HTMLを組み立てていないか f-string や + で SQL/shell=True/innerHTML を検索
例外の握りつぶし except Exception: pass、catch (e) {} を検索
秘密の直書き password =、api_key =、-----BEGIN を検索。.env がコミットされていないか
ログ出力の引数 パスワード・トークン・本文全体を渡していないか
新しい依存 追加理由、メンテナンス状況、postinstall の有無、ロックファイルの差分

次のステップ:ASVS と Cheat Sheet Series

Top 10 は「意識向上のための文書」で、要件の一覧ではありません。 実装や検証の基準として使うなら、OWASP ASVS(Application Security Verification Standard、2025年5月に 5.0 を公開。L1〜L3 の3段階で要件を定義)を参照し、個別の実装方法は OWASP Cheat Sheet Series(cheatsheetseries.owasp.org。Authorization、Input Validation、Logging、Error Handling などテーマごとの実践ガイド)にあたると、この記事の各項目をそのまま深掘りできます。

よくある質問

Q. OWASP Top 10 2025 は正式版ですか? A. 2025年11月に公開され、公式サイト(top10.owasp.org)に 2025 年版として掲載されています。OWASP プロジェクトページも「最新の公開版は 2025」としています。項目の名称と順位は本記事の早見表の通りです。

Q. SSRF は対策しなくてよくなったのですか? A. いいえ。独立した項目ではなくなりましたが、CWE-918 として A01 に含まれています。本記事の実行例のように、宛先の許可リストや内部IPの検査は引き続き必要です。

Q. まず何から手を付けるとよいですか? A. 発生率が高く自動化しやすい順に、①依存の監査(pip-audit / npm audit を CI に入れる)、②セキュリティヘッダーと debug 設定のテスト、③所有者チェックの自動テスト、の3つから始めると、A03・A02・A01 を短期間で押さえられます。

Q. Top 10 に準拠すれば安全と言えますか? A. Top 10 は代表的なリスクの一覧で、網羅的な基準ではありません。要件として扱うには ASVS を使い、実装は Cheat Sheet Series を参照し、テストは自動化して継続する、という組み合わせが公式の位置づけに沿っています。

まとめ

  • OWASP Top 10 2025 は、A03「ソフトウェアサプライチェーンの不備」と A10「例外条件の不適切な処理」が新設され、SSRF は A01 に統合された
  • アクセス制御は「ログイン済みか」ではなく「そのデータの所有者か」で判定し、他人の注文IDは 404 にする
  • SSRF は宛先の許可リスト、または名前解決後の内部IP検査+リダイレクト無効で防ぐ
  • セキュリティヘッダー・debug 無効・不要画面の非公開は、自動テストで「付いているか」を確かめる
  • pip-audit / npm audit を CI で回し、ロックファイルと SHA 固定で再現性を保つ
  • 例外は入力検証で減らし、想定外のものは error_id 付きの一般化した応答にして詳細はログへ
  • ログは機密をマスクして1行の JSON にし、通知条件まで決める

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「OWASP Top 10 2025|全10項目を脆弱コードと修正例で解説」

株式会社RJCでは、OWASP Top 10 のような公開された指針を手元で動かして確かめる姿勢を、プロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?