- サイバーセキュリティ 🛡️
OWASP Top 10 2025|全10項目を脆弱コードと修正例で解説
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期連続の黒字決算。