عرض عملي: حقن SQL ثم إصلاحه بـ PreparedStatement
في هذا الدرس 11 موضوعات
هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.
Lesson Overview
This is the closing demonstration of the module, a three-act story on the Library data: attack, patch badly, fix properly. In lesson 4 you attacked a one-column query. Here the query has two inputs, a title and a minimum number of copies, the way a real search form does. You will watch a tempting "remove the quotes" patch stop one attack and let two others straight through, and then fix the method with parameters and re-run every attack.
Use only your own scratch database and test data. The attack strings are for learning to defend, and should never be used against a system you do not own.
You need the Book record, the Db helper, the JdbcBookRepository from lesson 6, and a test database with books and members tables. This lesson prepares BEG-055-LAB1.
Learning Objectives
By the end of this lesson you will be able to:
- Reproduce an injection against a query with a text input and a numeric input.
- Explain why removing quotes blocks only one entry point.
- Rewrite the method with a
PreparedStatementand validated numeric input. - Re-run every attack as evidence that the fix works.
- Turn the attacks into regression tests.
- State the rule: parameters for every value, always.
Written Explanation
The scenario
A search page offers two fields: title and minimum copies. Everything from a web page arrives as text, so a beginner keeps both as String:
String sql = "SELECT id, title, isbn, copies FROM books WHERE title = '" + title
+ "' AND copies >= " + minCopies;The title is wrapped in quotes because it is text. The number is not, because SQL numbers need no quotes. That difference matters in the demo. Our test data: Clean Code (2 copies), Effective Java (1), Java Basics (0), and members Mona and Omar.
Step 1: the honest call and the first attack
Honest input ("Clean Code", "1") gives the SQL ... title = 'Clean Code' AND copies >= 1 and returns one book. Now the attack from lesson 4, with -- to comment out the rest of the line (including our AND copies part):
title = x' OR '1'='1' -- minCopies = 0
SQL: ... WHERE title = 'x' OR '1'='1' --' AND copies >= 0
2 attack: [Clean Code, Effective Java, Java Basics] (all three books)Step 2: the planned mistake, "just remove the quotes"
A developer who has heard "quotes are the problem" adds one line:
static String stripQuotes(String s) { return s.replace("'", ""); }
// then: build the SQL from stripQuotes(title) and stripQuotes(minCopies)Re-running the same attack now:
SQL: ... WHERE title = 'x OR 1=1 --' AND copies >= 0
3 patched vs 2: []It looks fixed: the apostrophes vanished, so the whole input stayed inside the quoted title and matched nothing. A developer who stops here ships a hole. The number field has no quotes to remove:
minCopies = 0 OR 1=1
SQL: ... WHERE title = 'Clean Code' AND copies >= 0 OR 1=1
4 patched vs numeric: [Clean Code, Effective Java, Java Basics] (everything again)Because AND binds tighter than OR, the trailing OR 1=1 makes the whole condition true. And the UNION attack needs no apostrophes either:
minCopies = 5 UNION SELECT id, name, name, 1 FROM members
5 patched vs union: [Book[id=3, title=Mona ...], Book[id=4, title=Omar ...]]The attacker matched the four columns of our SELECT and the member names came back inside Book objects. (A first attempt with a quoted 'x' fails after quote stripping, with column "x" does not exist; the attacker just uses a column name instead. The lesson: a filter makes attacks slightly harder, not impossible.) In general, blacklists, hand-written escaping, and "clean the input" functions depend on guessing every trick in advance. The OWASP cheat sheet calls manual escaping strongly discouraged and recommends parameterised queries first.
Step 3: the real fix
static List<Book> findSafe(Connection c, String title, String minCopies) throws SQLException {
int min;
try {
min = Integer.parseInt(minCopies);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("minCopies must be a whole number");
}
String sql = "SELECT id, title, isbn, copies FROM books WHERE title = ? AND copies >= ?";
List<Book> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, title);
ps.setInt(2, min);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
out.add(new Book(rs.getInt("id"), rs.getString("title"),
rs.getString("isbn"), rs.getInt("copies")));
}
}
}
return out;
}Two defences work together, and it helps to see which does what:
- Parameters. The SQL text is a fixed constant. The title is bound with
setString, the number withsetInt. PostgreSQL receives the query text and the values in separate protocol messages (Parse, then Bind), so a value is never parsed as SQL. - Input validation. A text field that should be a number is converted to an
intbefore it gets near the database. Anything else is rejected with a clear message for the user. Validation is good practice (it gives a friendly error), but it is not the security measure; the parameters are.
Now every attack again:
safe: [] (title attack: just an odd title, no match)
safe: rejected: minCopies must be a whole number (0 OR 1=1)
safe: rejected: minCopies must be a whole number (UNION ...)
safe normal: [Book[id=12, title=Clean Code, isbn=111, copies=2]]The honest call still works, and no attack returns data it should not.
The rule, stated plainly
Every value goes through a ? parameter, always, with no exceptions for "safe-looking" values. What cannot be a parameter (a column name or sort direction) is chosen from a short list of literals in your code, never copied from the user. Lesson 4 explains this.
Examples
Example 1: the attack as a repeatable script (simple)
Keep the attacks in one small list so you can re-run them after any change:
String[][] attacks = {
{"x' OR '1'='1' --", "0"},
{"Clean Code", "0 OR 1=1"},
{"Clean Code", "5 UNION SELECT id, name, name, 1 FROM members"}
};Run each against the unsafe method (expect leaks), the quote-stripping method (expect leaks for the second and third), and the safe method (expect nothing or a rejection). A table of results is your evidence:
| Attack | Unsafe | Quotes stripped | Parameters |
|---|---|---|---|
| Always-true title | all books | none | none |
Numeric OR 1=1 | all books | all books | rejected |
Numeric UNION | member names | member names | rejected |
(The "unsafe" column for the numeric attacks is by construction: the unfiltered text reaches the SQL unchanged. We ran the first three configurations in Step 1 and Step 2.)
Example 2: attacks as regression tests (practical)
The repository from lesson 6 has a one-parameter findByTitle. Add permanent tests so nobody ever reintroduces concatenation:
@Test
void quoteAttackReturnsNothing() throws SQLException {
repo.save(new Book(0, "Clean Code", "111", 2));
assertTrue(repo.findByTitle("x' OR '1'='1' --").isEmpty());
}
@Test
void unionAttackReturnsNothing() throws SQLException {
repo.save(new Book(0, "Clean Code", "111", 2));
assertTrue(repo.findByTitle("x' UNION SELECT id, title, isbn, copies FROM books --").isEmpty());
}Both pass because the input is only ever a value. If a teammate "optimises" the method into string-building, these tests turn red.
Example 3: reviewing a pull request (realistic)
A teammate's change contains:
String sql = "SELECT * FROM books ORDER BY " + sortColumn; // sortColumn from the requestThe review answer, step by step: (1) a column name cannot be a ?, so parameters do not help here; (2) the value comes from the user, so concatenating it is exactly the lesson 4 risk; (3) the fix is an allow-list in code that maps the user's choice to one of a few fixed literals, falling back to a default for anything else. Spotting the + that joins a variable into SQL is the whole skill.
Backend Insight
Real systems rarely have one input. A search endpoint has a title, a range, a sort order, and a page size, and each is a chance to concatenate "just this one". Attackers look for the one that was missed, and the numeric field is the classic miss because nothing about it looks dangerous.
Two team habits cover almost everything: review for the + in SQL, and keep attack inputs in your automated tests. Add least privilege on top: the database user your application connects as should have only the rights it needs (for example, no DROP), so that a mistake does less damage. And remember that parameters stop injection but do not replace checking who is allowed to see which record.
Common Mistakes
Mistake 1: Treating numbers as safe
A number that arrives as a String is text until you convert it. Convert and bind, or reject.
Mistake 2: Cleaning input instead of binding
Quote removal, keyword blocking, and manual escaping each fail somewhere, as Step 2 showed. Bind.
Mistake 3: Testing only the attack you thought of
Our patch stopped the first attack and lost to the second and third. Test several entry points, including every numeric one.
Mistake 4: Validation without parameters
Validation reduces bad input but is not a defence by itself, because you cannot predict every legal-looking string (an apostrophe in a name is legitimate). Use both.
Mistake 5: Dynamic SQL hidden in a helper
A small buildQuery(String... parts) helper that joins strings is still concatenation, just in another file. Search the whole project for it.
Exercises
Level A: Foundation
- In the unsafe query, which input is wrapped in quotes and which is not? Why does that matter?
- Why does
AND copies >= 0 OR 1=1return every book? - Which two measures make
findSafesafe, and which one is the security measure?
Level B: Application
- Implement the unsafe method and the quote-stripping method in a scratch class. Run the three attacks against both and fill in a table like Example 1's.
- Implement
findSafeand run the same attacks plus one honest call. Paste your output. - Add the two regression tests from Example 2 to your repository test class and run
mvn testagainst the test database. Report the "Tests run" line.
Level C: Challenge
- Extend
findSafewith a third input,sortBy, accepted valuestitleandcopies, anything else falling back totitle. Show the SQL string your code builds and say where each part comes from. - Write a short note (five sentences) for a new teammate titled "Why we never clean input to prevent SQL injection". Use the numeric attack from Step 2 as your evidence.
أظهر الحلول
A1. The title is in quotes; the number is not. Quote-stripping defends only the title position. The numeric position has no quotes to break out of, so stripping does nothing there.
A2. AND is evaluated before OR, so the condition is (title = 'Clean Code' AND copies >= 0) OR 1=1. 1=1 is always true, so every row matches.
A3. Parameters and input validation. Parameters are the security measure; validation gives users a clear error and stops obviously wrong values early.
B1. Expected (from our run): unsafe leaks all books on attack 1; quote-stripped gives [] for attack 1 but leaks all books for 0 OR 1=1 and member names for the UNION.
B2. Expected: [], a rejection message, another rejection message, and for the honest call the one Clean Code row, as in Step 3.
B3. Expected Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 if you keep the five tests from lesson 6 plus these two. (A different number is fine if your class differs.)
C1.
String column = "copies".equals(sortBy) ? "copies" : "title";
String sql = "SELECT id, title, isbn, copies FROM books WHERE title = ? AND copies >= ? ORDER BY " + column;The ? positions carry the two user values. The only concatenated piece, column, is always one of two literals chosen by your code, never the user's text.
C2. Sample: "Removing quotes stopped the title attack but the numeric field has no quotes, so 0 OR 1=1 returned every book. A blacklist has to predict every trick; an attacker needs one that was missed. Parameters change the problem: the SQL text is fixed and the user's text is only a value, so it is never parsed as code. Validation helps users but is not the defence. So we bind every value, always."
Mini Challenge
Write a one-page "SQL safety checklist" for your Library project with exactly five checks you will run before every pull request (for example: no + inside SQL strings except constants, every value bound, numeric input converted before use, sort columns chosen from an allow-list, attack tests present). Then apply it to the JdbcBookRepository and the LoanService from lesson 5, and tick or fix each line.
Check your work: you can point to the test that would fail if someone concatenated a value, and your checklist mentions both the numeric and the text case.
Summary & Next Lesson
Key takeaways
- Removing quotes stopped the title attack and failed against a numeric
OR 1=1and aUNIONthat needs no quotes. - Filtering input is a guessing game; parameters change the structure of the problem.
findSafe: constant SQL,setString/setIntbinding, and numeric input converted first.- Keep attack inputs as regression tests and review every
+in SQL. - Rule: parameters for every value, always. Structural parts come from an allow-list in code.
Next lesson
This was the last lesson of the module on JDBC essentials. Next, apply everything in the lab, BEG-055-LAB1 (Library DAO): the BookRepository with save, findById, findByTitle, and delete, tested against a local test database. I did not read the title of the next module in the specs, so I do not name it here.
Self-check
- Why did quote removal fail against
0 OR 1=1? - What does
AND ... OR 1=1do to aWHEREclause? - Which two separate PostgreSQL protocol steps explain why a bound value is not parsed as SQL?
- What is validation good for, if it is not the defence?
- How do you handle a user-chosen sort column?
أظهر الإجابات
- The numeric field has no quotes, so there was nothing to remove and the text went into the SQL unchanged.
- It makes the whole condition true for every row, because
1=1is always true. - Parse (query text with placeholders) and Bind (the values).
- Early rejection and friendly error messages; the parameters are what stops injection.
- Map the choice to one of a few literal column names in your code and concatenate only that literal.
References
- OWASP, SQL Injection Prevention Cheat Sheet. It supports parameterised queries as the primary defence, the warning against manual escaping, and the allow-list for structural choices such as sort order.
- PostgreSQL, Message Flow (protocol). It supports the separate Parse and Bind messages, with values supplied outside the query text. The page does not use the phrase "SQL injection", so the safety conclusion is an inference from that design.
- All outputs above come from real runs on PostgreSQL 18 with driver 42.7.8 against a scratch database. The operator-precedence remark (
ANDbeforeOR) is standard SQL behaviour that the run confirmed; I did not cite a manual page for it. The two regression tests in Example 2 were run together with the five tests from lesson 6:Tests run: 7, Failures: 0.
التطبيق
طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.
تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.