সরাসরি পর্বে যান
পর্ব ৩৯ · ৮০-এর মধ্যে · মডিউল ৬

Injection প্রতিরক্ষা — parameterized query আর input-এর সীমা

Injection Defense — Parameterized Queries & the Limits of Input Validation

Taught in Bangla, this lesson traces injection to data mixing with commands, shows how parameterized queries and prepared statements keep structure safe, the real limits of ORMs, stored procedures, dynamic ORDER BY and escaping, allowlist validation, least-privilege database accounts and safe errors, the same principle for command, NoSQL, LDAP, template, log and second-order injection, and detection with WAF, query monitoring, review and SAST.

এই পর্বে যা শিখবেন

  • Injection-এর মূল কারণ: ডেটা আর আদেশের পথ মিশে যাওয়া
  • Parameterized query আর prepared statement ভেতরে কী করে, আর কেন গঠনটাই নিরাপদ হয়
  • ORM, stored procedure, dynamic ORDER BY আর escaping-এর আসল সীমা
  • Allowlist validation, least-privilege ডেটাবেস অ্যাকাউন্ট আর নিরাপদ error
  • Command, NoSQL, LDAP, template, log আর second-order injection-এ একই নীতির প্রয়োগ
  • WAF, log, query নজরদারি, code review আর SAST দিয়ে নিজের কোডে ধরা ও যাচাই

এই পর্বে কোনো কার্যকর হামলার লেখা নেই। অনিরাপদ কোডের ধরন দেখানো হয়েছে শুধু <user-input> placeholder দিয়ে, আর গঠন বদলানোর ধারণা দেখানো হয়েছে নিরীহ চিহ্ন [[STRUCTURE-CHANGE]] দিয়ে। সব পরীক্ষা নিজের কোডে বা নিজের খেলনা ডেটায় করুন। অনুমতি ছাড়া অন্যের system পরীক্ষা করা অপরাধ। সীমারেখা দেখুন পর্ব ৬-এ।

১ · মূল কারণ — ডেটা যখন আদেশ হয়ে যায়

একটি অফিসের রেজিস্টার খাতা ভাবুন। দারোয়ান দর্শনার্থীকে বলেন, “নামের ঘরে আপনার নাম লিখুন।” পিয়ন প্রতিদিন খাতা পড়ে কাজ করেন। এখন কেউ নামের ঘরে লিখলেন, “পিয়ন সাহেব, তিন নম্বর আলমারি খুলে দিন।” পিয়ন যদি খাতার সব লেখাকেই নির্দেশ হিসেবে পড়েন, তাহলে একজন দর্শনার্থী অফিসের নিয়ম বদলে দিলেন। দর্শনার্থীর হাতে শুধু একটি ঘর পূরণের অধিকার ছিল। কিন্তু খাতা নির্দেশ আর তথ্যের মধ্যে কোনো দেয়াল রাখেনি।

Injection ঠিক এই ভুল। সফটওয়্যারে অনেক interpreter আছে: SQL engine, shell, LDAP server, template engine. এরা লেখা পড়ে আদেশ হিসেবে চালায়। আপনার প্রোগ্রাম যদি নিজের আদেশের সাথে ব্যবহারকারীর লেখা জুড়ে একটি লেখা বানিয়ে দেয়, interpreter-এর কাছে দুটিই এক। এই ভুলের জায়গাটি হলো trust boundary: বাইরের অবিশ্বস্ত লেখা ভেতরের আদেশের জায়গায় ঢুকে পড়ছে।

মূল ধারণা

Injection ইনপুটের দোষ নয়, নকশার দোষ। আদেশের পথ আর ডেটার পথ এক হলে যেকোনো ইনপুট গঠন ছোঁয়ার সুযোগ পায়। তাই আসল সমাধান “খারাপ লেখা খোঁজা” নয়, পথ দুটিকে আলাদা করা। এরপর ইনপুটে যা-ই থাক, সে শুধু ডেটাই থাকবে।

একই পথ বনাম আলাদা পথ

Mixed text reaches the parser; a separated value does not

একই পথে মিশ্রণ বনাম আলাদা পথে bind অনিরাপদ: একই পথ কোড: SQL লেখা ইনপুট (অবিশ্বস্ত) জোড়া একটি লেখা Parser parser জানে না কোন অক্ষর কার লেখা নিরাপদ: আলাদা পথ কোড: ? সহ SQL Parser Execute গঠন স্থির ইনপুট (অবিশ্বস্ত) bind: শুধু মান মান কখনো parser-এর কাছে যায় না
ছবি ৩৯.১ — প্রতিরক্ষার মূল কথা: ইনপুট যেন parser-এর চোখেই না পড়ে।

একটি খেলনা দিয়ে ধারণাটি চোখে দেখা যাক। নিচের parser আর তার ভাষা বানানো, real নয়, শুধু ধারণার জন্য। এতে কোনো ডেটাবেস নেই। [[STRUCTURE-CHANGE]] একটি নিরীহ চিহ্ন, যা বোঝায় “এই token-টি parser গঠন হিসেবে পড়ল।” প্রতিটি token-এর ধরন এক অক্ষরে ছাপা হয়: W শব্দ, V মান, S গঠন, ? ফাঁকা ঘর।

// TOY ONLY: a made-up language and parser in memory. Not real SQL.
// real নয়, শুধু ধারণার জন্য
const MARK = '[[STRUCTURE-CHANGE]]';
const WORDS = new Set(['FIND', 'customer', 'WHERE', 'name', 'IS']);

function parse(text) {             // reads every token as language
  return text.split(' ').map(tok => {
    if (tok === MARK) return 'S';
    if (tok === '?') return '?';
    return WORDS.has(tok) ? 'W' : 'V';
  }).join('');
}

function concatQuery(input) {      // unsafe pattern: input enters the text
  return parse('FIND customer WHERE name IS ' + input);
}
function boundQuery(input) {       // structure first, value kept apart
  return { shape: parse('FIND customer WHERE name IS ?'), value: input };
}

const EXPECTED = 'WWWWWV';
for (const input of ['Rahim', 'Rahim Uddin', 'Rahim ' + MARK]) {
  const c = concatQuery(input);
  const b = boundQuery(input);
  console.log('input :', input);
  console.log('  concat shape:', c, c === EXPECTED ? '(as designed)' : '(STRUCTURE CHANGED)');
  console.log('  bound  shape:', b.shape, '(fixed), value:', JSON.stringify(b.value));
}

আউটপুটে জোড়া পদ্ধতিতে শুধু Rahim নকশামতো WWWWWV দেয়। সৎ দুই শব্দের নাম Rahim Uddin দিলেই shape হয় WWWWWVV, আর চিহ্নসহ ইনপুটে WWWWWVS, দুটিই (STRUCTURE CHANGED). Bound পদ্ধতিতে তিন ক্ষেত্রেই shape WWWWW?, কারণ parser ইনপুট পড়েইনি। লক্ষ করুন, এখানে “খারাপ লেখা খোঁজার” কোনো যুক্তিই নেই। নিরাপত্তা এসেছে শুধু কাজের ক্রম থেকে।

এই ভুল সফটওয়্যারের চেয়েও পুরোনো। ১৯৬০-৭০-এর দশকে দূরপাল্লার ফোন লাইনে নিয়ন্ত্রণের সুর আর কথা যেত একই লাইনে, যাকে বলে in-band signaling. গ্রাহকের দিক থেকে ঠিক সেই সুর বাজালে নেটওয়ার্ক তাকে নিয়ন্ত্রণের আদেশ ভাবত। প্রতিকার ছিল নিয়ন্ত্রণের জন্য আলাদা signaling network, যা পরে SS7-এ পরিণত হয়। নকশার শিক্ষা হুবহু একই: পথ আলাদা করুন।

২ · SQL injection — গঠন ভাঙে কীভাবে

SQL injection সবচেয়ে পরিচিত উদাহরণ। ঝুঁকিটি CWE তালিকায় CWE-89 নামে আছে, আর OWASP Top 10:2025-এ Injection বিভাগ পঞ্চম স্থানে। বিশদ মানচিত্র পাবেন পর্ব ৩৭-এ। কারণটি ডেটাবেস কোর্সে আগেই দেখেছেন ডেটাবেস নিরাপত্তা পর্বে। এখানে আমরা রক্ষকের পুরো নকশায় যাব। অনিরাপদ কোডের ধরনটি এমন:

# Unsafe coding pattern. Do not write this.
sql = "SELECT id, city FROM customers WHERE name = '" + "<user-input>" + "'"
db.execute(sql)

এখানে ইনপুট SQL-এর উদ্ধৃতিচিহ্নের ভেতরে বসছে। কিন্তু উদ্ধৃতিচিহ্নটি SQL-এর গঠনের অংশ। ইনপুটের ভেতরেই একই চিহ্ন থাকলে পড়ার সীমানা বদলে যায়। ক্ষতি নির্ভর করে পরিস্থিতির ওপর: যে সারি দেখার কথা নয় তা দেখা, ডেটা বদলানো বা মোছা, এমনকি কিছু configuration-এ server-এর আরও ভেতরে পৌঁছানো। তাই একটি মাত্র ফর্মের ভুল CIA triad-এর তিনটি দিকেই আঘাত করতে পারে।

প্রমাণের জন্য হামলা লাগে না। একজন সৎ গ্রাহকের নামই যথেষ্ট। নিচের কোড RAM-এর ভেতরে একটি খেলনা SQLite টেবিল বানিয়ে O'Brien নামটি দুইভাবে খোঁজে:

# In-memory toy table. An honest name with an apostrophe, no attack.
import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, city TEXT)")
db.executemany("INSERT INTO customers (name, city) VALUES (?, ?)", [
    ("Rahim Uddin", "Dhaka"),
    ("O'Brien", "Sylhet"),
    ("Karim Ali", "Rajshahi"),
])

def glued(name):      # unsafe pattern: the value is glued into the SQL text
    sql = "SELECT id, city FROM customers WHERE name = '" + name + "'"
    try:
        return db.execute(sql).fetchall()
    except sqlite3.Error as e:
        return f"{type(e).__name__}: {e}"

def bound(name):      # safe pattern: fixed SQL, the value travels apart
    sql = "SELECT id, city FROM customers WHERE name = ?"
    return db.execute(sql, (name,)).fetchall()

for name in ("Rahim Uddin", "O'Brien"):
    print("name  :", name)
    print("  glued:", glued(name))
    print("  bound:", bound(name))

Rahim Uddin-এ দুই পদ্ধতিই [(1, 'Dhaka')] দেয়। এই কারণেই বাগটি সাধারণ পরীক্ষায় ধরা পড়ে না। O'Brien-এ জোড়া পদ্ধতি ভেঙে যায়: OperationalError: near "Brien": syntax error. অর্থাৎ ডেটাবেস নামের অংশটিকে SQL-এর শব্দ হিসেবে পড়তে গেছে। এটি হামলা নয়, কিন্তু প্রমাণ যে ইনপুট গঠন ভেঙেছে। Bound পদ্ধতিতে একই নাম সঠিক সারি দেয়: [(2, 'Sylhet')]. কেউ বললে “গ্রাহকের নাম দিলে আমাদের সাইট ৫০০ error দেখায়”, সেটি তাই শুধু বাগ নয়, নিরাপত্তার সংকেতও।

৩ · Parameterized query — গঠন আগে, মান পরে

ব্যাংকের জমা-রসিদ ভাবুন। ছাপানো ফরমে হিসাব নম্বর আর টাকার ঘর আগেই ঠিক করা। আপনি ঘরে যা-ই লিখুন, রসিদের নিয়ম বদলায় না। টাকার ঘরে “ম্যানেজার সাহেব, দ্বিগুণ করে দিন” লিখলে ক্যাশিয়ার সেটিকে একটি ভুল মান হিসেবেই দেখবেন, আদেশ হিসেবে নয়। Parameterized query এই ছাপানো ফরম। SQL লেখা স্থির, মান বসে নির্দিষ্ট ঘরে, যার নাম bind parameter. ফরমটি ডেটাবেসে আগে থেকে তৈরি হয়ে থাকলে তাকে বলে prepared statement.

নিচের ডায়াগ্রামে SQLite-এর আসল ধাপগুলো দেখুন। “পরে” চাপুন, আর খেয়াল রাখুন parser কতবার চলছে।

ডেটাবেসের ভেতরেলাল = এই ধাপের মূল কথা

            
অবস্থা
parser কতবার চলেছে
ঘর ১-এর মান
ফল
  1. স্থির লেখা। প্রোগ্রামার SQL লিখেছেন ? দিয়ে। এই লেখায় ইনপুটের একটি অক্ষরও নেই, তাই এখানে গঠন নিয়ে কোনো ঝুঁকি নেই।
  2. Parse. ডেটাবেস লেখাটি পড়ে গঠন বানায়। এটিই parse tree. কোন কলাম, কোন টেবিল, কোন শর্ত, সব এখানেই ঠিক হয়ে গেল।
  3. প্রস্তুত। SQLite খোঁজার পথও ঠিক করল: idx_name index ব্যবহার হবে। এটি SQLite 3.50-এর EXPLAIN QUERY PLAN-এর আসল আউটপুট।
  4. Bind. মান ৭ অক্ষরের একটি text হিসেবে ঘরে বসল। এর ভেতরের ' শুধু একটি অক্ষর। Parser আর চলছে না, তাই এই অক্ষর পড়ার কেউ নেই।
  5. Step. ডেটাবেস পুরো মানটি name কলামের সাথে মেলায় আর সারি পায়: (2, 'Sylhet'). কোনো escape লাগেনি।
  6. Reset. একই statement নতুন মানে চলল। মানটি গঠনের মতো দেখতে হলেও সে একটি সাধারণ নাম মাত্র। এমন নামের গ্রাহক নেই, তাই ফল ০ সারি। Parser এখনো মাত্র একবারই চলেছে।

দুটি আলাদা জিনিস এখানে গুলিয়ে ফেলা সহজ। গঠন বলে কী চাওয়া হচ্ছে। Query plan বলে কীভাবে খোঁজা হবে। আর মান বলে কার জন্য খোঁজা হবে। বাসের রুট আর যাত্রীর মতো: রুট ঠিক করা থাকে, যাত্রী বদলায়, যাত্রী বাসের রুট লিখে দেন না।

একই query, ভিন্ন মানSQLite 3.50-এ আসল ফল

            
অবস্থা
গঠন কে ঠিক করল
Parser দেখল মান?
  1. আগে গঠন। ইনপুট আসার আগেই ডেটাবেস জানে এটি একটি SELECT, আর শর্ত একটিমাত্র সমতা।
  2. তারপর পথ। SQLite ঠিক করল idx_name দিয়ে খুঁজবে। এই পথ ইনপুট থেকে আসেনি, এসেছে টেবিল আর index থেকে।
  3. প্রথম মান। একই পথে Rahim Uddin পাওয়া গেল, ফল (1, 'Dhaka').
  4. দ্বিতীয় মান। উদ্ধৃতিচিহ্ন থাকলেও পথ একই, ফল (2, 'Sylhet').
  5. তৃতীয় মান। চিহ্নটি গঠনের মতো দেখালেও সে মান। পথ একই, সারি নেই, তাই ফাঁকা তালিকা।
  6. তুলনা। জোড়া লেখায় প্রতিটি ইনপুট নতুন লেখা বানায়, আর parser সেটি পড়ে। গঠন এখানে ইনপুটের হাতে। O'Brien-এ parse-ই ব্যর্থ, তাই plan হয়নি।

ভুল সরলীকরণ থেকে সাবধান: “মান বদলালে plan কখনো বদলায় না” কথাটি সব ডেটাবেসে ঠিক নয়। PostgreSQL-এর মতো কিছু ব্যবস্থা মান দেখে ভিন্ন index বেছে নিতে পারে, কারণ পরিসংখ্যান দেখে কোনো মান দুর্লভ, কোনো মান সাধারণ। যা বদলায় না তা হলো গঠন। নিরাপত্তা আসে গঠন স্থির থাকা থেকে, plan স্থির থাকা থেকে নয়।

প্রতিটি ভাষার driver-এ ঘরের চিহ্ন আলাদা, নীতি এক। চিহ্নটি ভুলভাবে ব্যবহার করলে পুরো সুরক্ষা হারিয়ে যায়:

পরিবেশPlaceholderযে ভুল থেকে সাবধান
Python sqlite3? বা :nameমান দ্বিতীয় argument-এ দিন, f"..."-এ নয়
Python psycopg (PostgreSQL)%s%s দেখতে ফরম্যাটের মতো। sql % x লিখলে ওটি জোড়া লেখাই
Java JDBC?PreparedStatement-এও আগে থেকে জোড়া লেখা দিলে লাভ নেই
PHP PDO? বা :nameDriver মান client-এ বসাচ্ছে কি না জানুন (emulation)
Node.js (node-postgres)$1Template literal-এর ${} placeholder নয়

আরও দুটি বাস্তব প্রশ্ন আসে। IN তালিকায় মানের সংখ্যা বদলালে ঘরের সংখ্যা কোডে গুনে তৈরি করুন, যেমন তিনটি মানের জন্য IN (?, ?, ?), মান যাবে bind হয়েই। আর LIKE-এ bind করলে গঠন নিরাপদ, কিন্তু মানের ভেতরের % তখনো LIKE-এর wildcard. দরকার হলে ESCAPE ধারা ব্যবহার করুন, আর ইনপুটের দৈর্ঘ্য সীমিত রাখুন।

যা থামায়, যা থামায় না

Bind করলে মান আর গঠন বদলাতে পারে না। কিন্তু “এই মান দেখার অধিকার তাঁর আছে কি না” bind জানে না। WHERE id = ?-এ অন্যের id দিলে সারি ঠিকই আসবে। এটি IDOR, যার প্রতিকার authorization যাচাই। বিস্তারিত পর্ব ৩৮-এ।

৪ · ORM আর stored procedure — কতটা বাঁচায়, কোথায় বাঁচায় না

অনেকে বলেন, “আমরা ORM ব্যবহার করি, তাই injection নেই।” কথাটি অর্ধেক সত্য। সাধারণ ব্যবহারে ORM নিজেই মান bind করে। কিন্তু ORM-এর ভেতরেও raw query লেখার দরজা আছে, আর সেখানে জোড়া লেখা ঢোকালে ORM কিছুই করতে পারে না। OWASP Top 10:2025-ও সতর্ক করে যে ORM-এর নিজস্ব query ভাষাতেও জোড়া লেখা বিপজ্জনক। SQLAlchemy দিয়ে তিনটি ধরন দেখুন:

# 1. Safer: the ORM binds the value
stmt = select(Customer).where(Customer.name == name)

# 2. Unsafe pattern: raw text built by glueing; the ORM cannot help
stmt = text("SELECT id FROM customers WHERE name = '" + "<user-input>" + "'")

# 3. Safer raw query: a named parameter
stmt = text("SELECT id FROM customers WHERE name = :name").bindparams(name=name)

Stored procedure-এর ক্ষেত্রেও একই কথা। Procedure ডাকার সময় মান bind করলে ডাকটি নিরাপদ। কিন্তু procedure-এর ভেতরে যদি dynamic SQL জোড়া লেখায় বানানো হয়, ঝুঁকি শুধু ডেটাবেসের ভেতরে সরে গেল। PostgreSQL-এর উদাহরণ:

-- Unsafe pattern inside a procedure: the value joins the SQL text
EXECUTE 'SELECT id FROM customers WHERE city = ''' || p_city || '''';

-- Safer: the value travels through USING
EXECUTE 'SELECT id FROM customers WHERE city = $1' USING p_city;
ব্যবস্থাযা সাহায্য করেযেখানে সাহায্য করে না
ORM-এর query APIমান নিজে bind করেRaw query, জোড়া লেখা filter, অনুরোধ থেকে আসা কলামের নাম
Stored procedureঅ্যাপকে টেবিলের সরাসরি অধিকার না দিয়েও কাজ করায়ভেতরে জোড়া dynamic SQL. Procedure নিজে জাদুকরী ঢাল নয়
Prepared statementগঠন আর মান আলাদা রাখেআগে থেকেই ইনপুট-জোড়া লেখা prepare করলে কোনো লাভ নেই

ডেটাবেসের দিকের বিস্তারিত দেখুন ORM আর migration আর stored procedure পর্বে।

৫ · যেখানে placeholder চলে না — dynamic ORDER BY আর escaping

ধরুন একটি অনলাইন বাজারে ক্রেতা দাম বা নাম অনুযায়ী পণ্য সাজাতে চান। স্বাভাবিক ভাবনা: ORDER BY ?। কিন্তু placeholder শুধু মানের জন্য। কলামের নাম (SQL identifier) আর ASC/DESC হলো গঠনের অংশ। তাই ঘরে কলামের নাম দিলে সে একটি সাধারণ text মান হয়ে যায়। কোনো error আসে না, শুধু সাজানো হয় না! নিচের ডায়াগ্রামে তিনটি বিকল্প পরখ করে সিদ্ধান্ত নিন।

একটি সাজানোর অনুরোধখেলনা পণ্যের তালিকা

            
অবস্থা
বিকল্প
রায়
  1. প্রশ্নটি বুঝুন। ক্রেতা বেছে নিচ্ছেন কোন কলাম আর কোন দিক। এগুলো মান নয়, গঠন। তাই এখানে সাধারণ bind চলবে না।
  2. Placeholder. SQLite 3.50-এ আসল ফল: সব সারির জন্য ধ্রুবক 'price' দিয়ে “সাজানো” হলো, তাই সারি আগের ক্রমেই থাকল। দাম অনুযায়ী বড় থেকে ছোট হলে dal, chal, tel আসার কথা ছিল। বাগটি নীরব, যা আরও বিপজ্জনক।
  3. Escape. কলামের নামে escape করে জোড়া দেওয়া ঠিক পথ নয়। OWASP-ও বলে identifier escape করা যায় না। আর ডেটাবেস-ভেদে নিয়ম বদলায়।
  4. Allowlist map. অনুরোধের লেখা SQL-এ যায় না। সেটি শুধু একটি চাবি। চাবি মিললে কোডে আগে থেকে লেখা স্থির SQL টুকরো বের হয়।
  5. অচেনা চাবি। map-এ না থাকলে খারিজ বা নিরাপদ ডিফল্ট। এটিই fail secure. অচেনা লেখা সাজানোর সুযোগই পায় না।
  6. চূড়ান্ত SQL. এবার যা ডেটাবেসে যাচ্ছে, তার প্রতিটি অক্ষর প্রোগ্রামারের লেখা। id ASC জুড়ে দেওয়ায় সমান দামেও ক্রম স্থির থাকে।

এই নীতি কোডে দেখুন। এখানে কোনো ডেটাবেস নেই। খেলনা অনুরোধগুলো শুধু SQL-এর স্থির টুকরো বেছে নেয়।

// Toy: map a requested sort to fixed SQL text. No database, no network.
const SORT_COLUMNS = new Map([
  ['price', 'price'], ['name', 'name'], ['newest', 'created_at'],
]);
const SORT_DIRS = new Map([['asc', 'ASC'], ['desc', 'DESC']]);

function orderBy(sort, dir) {
  const col = SORT_COLUMNS.get(sort);
  const way = SORT_DIRS.get(dir);
  if (col === undefined || way === undefined) return null;
  return `ORDER BY ${col} ${way}, id ASC`;   // only strings from the maps
}

const requests = [
  ['price', 'desc'],
  ['newest', 'asc'],
  ['price', 'DESC'],
  ['[[STRUCTURE-CHANGE]]', 'asc'],
  ['constructor', 'asc'],
];
for (const [sort, dir] of requests) {
  const clause = orderBy(sort, dir);
  console.log(sort.padEnd(21), dir.padEnd(5), '=>', clause ?? 'REJECTED (400)');
}

প্রথম দুটি অনুরোধে আউটপুট ORDER BY price DESC, id ASC আর ORDER BY created_at ASC, id ASC. newest চাবি কলামের আসল নাম লুকিয়েছে, যা বাড়তি লাভ। বাকি তিনটি REJECTED (400). DESC বড় হাতের বলে মেলেনি: allowlist ঠিক যা লেখা তাই মানে, চাইলে আগে ছোট হাতে আনুন। constructor আছে একটি কারণে: সাধারণ JavaScript object দিয়ে map বানালে এই চাবি prototype থেকে কিছু একটা ফেরত দিত। Map সেই ফাঁদ এড়ায়।

Escaping — কেন শেষ উপায়

Escaping মানে বিশেষ চিহ্নকে এমন রূপে লেখা, যাতে interpreter সেটিকে সাধারণ অক্ষর ভাবে। সমস্যা হলো, এটি মিশ্র পথটিকেই বাঁচিয়ে রাখে, আর প্রতিটি জায়গায় নিয়ম নিখুঁত হতে হয়: ডেটাবেসের ধরন, character encoding, server-এর সেটিং, উদ্ধৃতির ধরন। একটি জায়গায় ভুল হলেই সব শেষ। OWASP-এর SQL Injection Prevention Cheat Sheet তাই SQL-এ একে দৃঢ়ভাবে নিরুৎসাহিত করে। Parameterized query সম্ভব হলে escaping নয়। শুধু যেখানে সত্যিই আলাদা পথ নেই, সেখানে ওই interpreter-এর জন্য তৈরি পরীক্ষিত library ব্যবহার করুন, হাতে লেখা replace নয়। PostgreSQL-এ identifier দরকার হলে format('%I', ...) আছে, তবে সেখানেও আগে allowlist.

৬ · Input validation — বাড়তি স্তর, প্রধান ঢাল নয়

Input validation মানে ইনপুট প্রত্যাশিত ধরন, মাপ আর রূপের সাথে মেলে কি না যাচাই। মেট্রোর গেটে টিকিট দেখার মতো। এটি খুব কাজের, কারণ ভুল ডেটা শুরুতেই আটকে যায়। কিন্তু injection-এর একমাত্র ঢাল হিসেবে এটি যথেষ্ট নয়। OWASP Top 10:2025 সরাসরি বলে, এটি পূর্ণ প্রতিরক্ষা নয়, কারণ অনেক অ্যাপে বিশেষ চিহ্ন দরকার হয়। O'Brien তার জীবন্ত প্রমাণ।

Allowlist: যা চলবে তা বলুন

Allowlist বলে কেমন হওয়া চাই: ১১ অঙ্ক, ০১ দিয়ে শুরু, নির্দিষ্ট তালিকার একটি শহর। তালিকার বাইরের সব আটকে যায়।

পছন্দের পথ

Denylist: যা চলবে না তা বলুন

Denylist “খারাপ” চিহ্ন খোঁজে। এতে দুই বিপদ: নতুন ধরন বাদ পড়ে, আর সৎ মানুষ আটকে যান। ' নিষিদ্ধ করলে O'Brien সাইটে ঢুকতে পারবেন না।

নির্ভর করবেন না

Validation server-এ হতে হবে, কারণ browser-এর JavaScript যাচাই ব্যবহারকারীর হাতে। রূপ যাচাইয়ের আগে সাধারণ আকারে আনুন, আর দৈর্ঘ্যের সীমা দিন। নিচে বাংলাদেশি মোবাইল নম্বরের একটি যাচাইকারী। এটি বাংলা অঙ্ক ইংরেজিতে আনে, ফাঁকা আর হাইফেন সরায়, তারপর regex-এর সাথে পুরো লেখা মেলায়। ব্যবহৃত নম্বরগুলো বানানো।

// Toy validator for the Bangladeshi mobile number format. Made-up numbers.
const BN_DIGITS = '০১২৩৪৫৬৭৮৯';
const PHONE = /^(?:\+?88)?(01[3-9]\d{8})$/;   // anchors: match the whole text

function normalize(raw) {
  const digits = [...raw.trim()].map(ch => {
    const i = BN_DIGITS.indexOf(ch);
    return i >= 0 ? String(i) : ch;
  });
  return digits.join('').replace(/[\s-]/g, '');
}

function checkPhone(raw) {
  if (raw.length > 20) return 'REJECT (too long)';
  const m = PHONE.exec(normalize(raw));
  return m ? 'OK ' + '+88' + m[1] : 'REJECT (format)';
}

const samples = [
  '01700-000001',
  '০১৮০০০০০০০২',
  '+8801900000003',
  '01200000004',
  '0170000000',
  '01700000001 [[STRUCTURE-CHANGE]]',
];
for (const s of samples) console.log(s.padEnd(34), checkPhone(s));

প্রথম তিনটি একই আকারে আসে: OK +8801700000001, OK +8801800000002 আর OK +8801900000003. অর্থাৎ বাংলা অঙ্কও গৃহীত, যা স্থানীয় ব্যবহারকারীর জন্য জরুরি। 01200000004 ফরম্যাটে আটকায়, কারণ তৃতীয় অঙ্ক ৩ থেকে ৯ হতে হবে। ১০ অঙ্কের নম্বরও আটকায়। শেষটি দৈর্ঘ্যে REJECT (too long). সাবধান: Python-এ \d ডিফল্টে বাংলা অঙ্কও মেলায়, আর $ শেষের newline-এর আগেও মেলে, তাই সেখানে re.fullmatch ভালো। আর ফরম্যাট ঠিক মানে নম্বরটি আসল বা ব্যবহারকারীর নিজের, তা নয়। পাস করা মানও query-তে bind হয়েই যাবে।

কোন ইনপুটে কতটা validation চলে, তার একটি হিসাব: সংখ্যা, তারিখ, enum আর ফোন নম্বরে কড়া নিয়ম সম্ভব। মানুষের নাম, ঠিকানা আর মন্তব্যে নয়। সেখানে শুধু দৈর্ঘ্য আর encoding দেখা হয়, বাকি নিরাপত্তা দেয় bind. এটিই defense in depth: একটি স্তর অন্যটির বিকল্প নয়।

৭ · ভুল হলেও ক্ষতি সীমিত — least privilege আর নিরাপদ error

ধরে নিন, কোথাও একটি ভুল থেকে যাবে। তখন প্রশ্ন: ক্ষতি কতদূর যাবে? দোকানের কর্মচারীর হাতে ক্যাশ বাক্সের চাবি থাকলে এক কথা, পুরো মালিকের সিন্দুকের চাবি থাকলে আরেক কথা। ওয়েব অ্যাপের ডেটাবেস অ্যাকাউন্টই সেই চাবি। পর্ব ৩-এর least privilege এখানে blast radius ছোট রাখে। এটি injection থামায় না, ক্ষতির সীমা টানে।

-- Example for your own database. The login secret lives in a secret store.
CREATE ROLE shop_web LOGIN;
GRANT CONNECT ON DATABASE shop TO shop_web;
GRANT USAGE ON SCHEMA public TO shop_web;
GRANT SELECT ON products TO shop_web;
GRANT SELECT, INSERT ON orders TO shop_web;
-- No DROP, no ALTER, no access to admin tables, not a superuser.
CREATE ROLE shop_report LOGIN;          -- a separate read-only reporting role
GRANT SELECT ON orders TO shop_report;
  • প্রতি অ্যাপে আলাদা অ্যাকাউন্ট: ওয়েব, রিপোর্ট আর migration এক login-এ নয়। Migration-এর অধিকার চলমান অ্যাপের কাছে থাকবে না।
  • শুধু দরকারি অধিকার: শুধু পড়ার পাতায় শুধু পড়ার অ্যাকাউন্ট। সংবেদনশীল কলাম view দিয়ে আড়াল করুন।
  • কোনো superuser নয়: ডেটাবেস-প্রক্রিয়াও সাধারণ OS অ্যাকাউন্টে চালান। বিশদ পর্ব ২০-এ।

SQLite-এ user account নেই, তাই ধারণাটি একটি read-only সংযোগ দিয়ে দেখা যায়। স্থানীয়ভাবে Python 3.14 আর SQLite 3.50-এ চালিয়ে পাওয়া ফল নিচে:

db.execute("PRAGMA query_only = ON")          # this connection may only read
print(db.execute("SELECT id, total FROM orders").fetchall())
db.execute("DELETE FROM orders")              # refused by the database

# Output of a local run:
# [(1, 500)]
# sqlite3.OperationalError: attempt to write a readonly database

এটি শুধু ধারণার জন্য। query_only একটি সংযোগের সেটিং, অ্যাকাউন্টের অধিকার নয়। বাস্তবে server-ভিত্তিক ডেটাবেসের role অনেক শক্ত সীমা দেয়, আর SQLite-এ ফাইলটি mode=ro দিয়ে খোলা ভালো।

Error-এ schema ফাঁস নয়

কাঁচা database error-এ টেবিল, কলাম, কখনো পুরো SQL লেখা থাকে। ব্যবহারকারীর পর্দায় এটি দেখানো information disclosure (CWE-209), কারণ এতে ভেতরের নকশা বাইরে চলে যায়। নিয়ম সহজ: বিস্তারিত থাকবে server-এর log-এ, ব্যবহারকারী পাবেন একটি সাধারণ বার্তা আর একটি correlation ID, যাতে support দল ঘটনাটি খুঁজে পায়।

try:
    rows = find_orders(customer_id)
except DatabaseError:
    ref = new_reference()                       # e.g. "ref-7f3a"
    log.exception("order lookup failed", extra={"ref": ref})   # full detail, server only
    return error_page(500, "Something went wrong. Reference: " + ref)   # no SQL, no stack trace

Production-এ debug page আর stack trace বন্ধ রাখুন। এর সাধারণ configuration পর্ব ৪৪-এ দেখা হবে।

৮ · একই রোগ, অন্য ভাষায় — command থেকে log পর্যন্ত

SQL শুধু একটি উদাহরণ। যেখানেই লেখা জুড়ে কোনো interpreter-এ পাঠানো হয়, একই প্রশ্ন: “এখানে কি আলাদা পথ আছে?” থাকলে সেটি ব্যবহার করুন।

Command injection — shell-কে বাদ দিন

একটি অ্যাপ OS-এর কোনো প্রোগ্রাম চালাতে গিয়ে পুরো আদেশ এক লেখায় বানিয়ে shell-কে দিলে OS command injection (CWE-78)-এর ঝুঁকি। Shell নিজের চিহ্ন আর ব্যাকরণ দিয়ে লেখাটি পড়ে, তাই ইনপুট আদেশের অংশ হতে পারে। প্রতিকারের ক্রম:

  1. বাইরের প্রোগ্রামই না চালানো। ফাইল সংকোচনের কাজে ভাষার নিজস্ব library ব্যবহার করুন।
  2. Shell ছাড়া argument array. প্রোগ্রাম আর প্রতিটি argument আলাদা উপাদান, মাঝে কোনো shell নেই। এটি SQL-এর bind-এর সমতুল্য।
  3. ইনপুট allowlist-এ যাচাই, কারণ প্রোগ্রামটি নিজেই argument-কে option ভাবতে পারে। অনেক tool-এ -- লিখে option-এর শেষ চিহ্নিত করা যায়।
# Design example only, not for running here.
import gzip, shutil, subprocess

# Unsafe pattern: one text handed to a shell
subprocess.run("gzip -k " + "<user-input>", shell=True)

# Better: no shell, one list item per argument, a time limit
subprocess.run(["gzip", "-k", "--", checked_path], shell=False, check=True, timeout=10)

# Best here: no external program at all
with open(checked_path, "rb") as src, gzip.open(out_path, "wb") as dst:
    shutil.copyfileobj(src, dst)

Argument array shell-এর ঝুঁকি সরায়, তবে ফাইলের পথের ঝুঁকি নয়। checked_path অনুমোদিত ফোল্ডারের ভেতরে কি না তা আলাদা যাচাই, যা পর্ব ৪২-এ আসবে।

অন্য interpreter-গুলো, এক নজরে

ধরনকী মিশে যায়একই নীতির প্রতিরক্ষা
NoSQL injectionString-এর বদলে object এলে মান query-র operator হতে পারেধরন যাচাই (schema), driver-এর typed API, server-side script বন্ধ
LDAP injection (CWE-90)ইনপুট LDAP filter বা DN-এর গঠনে ঢোকেFilter আর DN-এর জন্য আলাদা encoder (RFC 4515, RFC 4514), allowlist. এখানে escaping-ই প্রধান পথ
Template injectionব্যবহারকারীর লেখা template-এর উৎস হয়ে যায়Template স্থির রাখুন, ইনপুট শুধু মান হিসেবে দিন, sandbox ব্যবহার করুন
Log injection (CWE-117)ইনপুটের নতুন লাইন দিয়ে ভুয়া log লাইন তৈরিStructured (JSON) log, যেখানে নতুন লাইন encode হয়; log-এর লেখা কখনো চালানো নয়

Log-কে নিরীহ ভাববেন না। ২০২১ সালের Log4Shell-এ সমস্যা ছিল এখানেই: log library লেখার ভেতরের বিশেষ অংশকে শুধু ছাপেনি, প্রক্রিয়াও করত। লেখার জিনিস তখন আদেশ হয়ে গিয়েছিল।

Second-order — দেরিতে জাগা বাগ

O'Brien নিবন্ধনের সময় bind হয়ে নিরাপদে জমা হলো। মাসখানেক পর একটি রাতের রিপোর্ট সেই নাম ডেটাবেস থেকে পড়ে SQL লেখায় জুড়ে দিল, ভেবে যে “আমাদের ডেটাবেসের মান তো বিশ্বস্ত।” রিপোর্ট ভেঙে গেল। এটিই second-order injection-এর ধারণা: ইনপুট ঢোকার জায়গায় নয়, ব্যবহারের জায়গায় বাগ। নিয়মটি তাই সহজ: উৎস যা-ই হোক, প্রতিটি query-তে bind. ডেটাবেস থেকে পড়া মানও ডেটা।

৯ · ধরা আর যাচাই — WAF, log, query নজরদারি, review আর SAST

প্রতিরোধের পরও জানতে হবে, কেউ চেষ্টা করছে কি না আর কোথাও বাগ রয়ে গেছে কি না। পর্ব ৪-এর threat model থেকে ঠিক করুন কোন পাতা সবচেয়ে সংবেদনশীল, আর সেখান থেকে শুরু করুন।

স্তরযা দেখেসীমা
WAFআসা অনুরোধের ধরন, পরিচিত নকশাএকটি জাল, দেয়াল নয়। আপনার query-র গঠন সে জানে না। O'Brien-কেও আটকাতে পারে
Application logখারিজ হওয়া ইনপুট, ৫০০ error-এর ঢেউলেখার আগে ইনপুট নিরাপদ করতে হয়, গোপন তথ্য বাদ দিতে হয়
Database logSyntax error, অধিকার-অস্বীকার (SQLSTATE class 42)সব statement log করলে খরচ বাড়ে
Query নজরদারিনতুন query fingerprint, অস্বাভাবিক টেবিল বা সারির সংখ্যাস্বাভাবিকের একটি baseline লাগে

Query fingerprint হলো মান বাদ দেওয়া গঠনের ছাপ। Parameterized অ্যাপে গঠনের সংখ্যা সীমিত, তাই “ওয়েব অ্যাকাউন্ট থেকে একটি একদম নতুন গঠন” বেশ অর্থবহ সংকেত। PostgreSQL-এর pg_stat_statements বা MySQL-এর performance schema এমন হিসাব রাখতে পারে। নিচে নিজের log-এর জন্য একটি শিক্ষামূলক Sigma নিয়ম। সীমা আর ক্ষেত্রের নাম আপনার ব্যবস্থা অনুযায়ী মিলিয়ে নিন। SIEM-এ নিয়ম সাজানো পর্ব ৫৬-এ দেখবেন।

title: SQL errors from the web account (teaching example)
status: experimental
description: Syntax or privilege errors from the app login. Tune on your own logs.
logsource:
  category: database
  product: postgresql
detection:
  selection:
    user: shop_web
    sql_state|startswith: '42'
  condition: selection
falsepositives:
  - A new release that shipped a broken query
level: medium

একটি হিসাব: WAF-এর alert কতটা বিশ্বাসযোগ্য

পর্ব ২৩-এর base-rate fallacy এখানে কাজে লাগে। ধরে নেওয়া সংখ্যা: একদিনে ১০,০০,০০০ অনুরোধ, এর মধ্যে আসল injection চেষ্টা ২০টি। WAF নিয়ম ৯০% চেষ্টা ধরে, আর সাধারণ অনুরোধের ০.১%-এ ভুল সতর্কতা দেয়।

  • সত্য ধরা: 20 × 0.90 = ১৮টি
  • ভুল সতর্কতা (false positive): 999,980 × 0.001 ≈ ১,০০০টি
  • Precision = 18 ÷ (18 + 999.98) ≈ ০.০১৭৭, অর্থাৎ প্রায় ১.৮%

অর্থাৎ ৯৮%-এর বেশি alert ভুল। এর মানে WAF অকেজো নয়। এর মানে WAF-এর একটি alert নিজে থেকে হামলার প্রমাণ নয়, আর এটি কোড ঠিক করার বিকল্পও নয়। WAF-এর সবচেয়ে ভালো ভূমিকা সাময়িক virtual patch: কোড ঠিক হওয়া পর্যন্ত সময় কেনা। বিস্তারিত CDN, WAF আর edge পর্বে, আর পর্ব ৪৪-এ।

নিজের কোডে পরীক্ষা

  • Code review-এ একটি প্রশ্ন: Review-এ প্রতিটি query দেখে জিজ্ঞেস করুন, “SQL লেখায় কি কোনো চলক ঢুকছে?” ঢুকলে সেটি কোথা থেকে এল? এই “parameter binding review” সস্তা, কার্যকর।
  • SAST: tool কোড না চালিয়েই taint analysis করে: অবিশ্বস্ত উৎস থেকে মান কি জোড়া লেখা হয়ে execute-এ পৌঁছায়? Build pipeline-এ রাখুন। False positive থাকবে। বিস্তারিত পর্ব ৪৯-এ।
  • নিরীহ regression test: নিজের অ্যাপে এমন test রাখুন যা O'Brien, বাংলা নাম, খুব লম্বা লেখা আর [[STRUCTURE-CHANGE]] দেয়, আর আশা করে সঠিক ফল বা পরিষ্কার 400, কখনো 500 নয়।

দ্রুত খসড়া তালিকার জন্য নিজের repository-তে সাধারণ খোঁজও চলে। এটি শুধু review-এর শুরু, প্রমাণ নয়:

# Run on your own source tree. Rough hints for review, expect false alarms.
grep -rnE 'execute\(\s*f"' src/
grep -rnE 'execute\(.*\+' src/
grep -rn 'shell=True' src/

অন্যের সাইটে, বা নিয়োগকর্তার system-এও, লিখিত অনুমতি ছাড়া এমন পরীক্ষা নয়। অনুমোদিত পরীক্ষার নিয়ম পর্ব ৫০-এ।

আরও গভীরে: PostgreSQL-এর extended query protocol-এ Parse, Bind আর Execute আলাদা বার্তা, আর MySQL-এ আছে COM_STMT_PREPARE ও COM_STMT_EXECUTE. তবে কিছু driver prepared-এর মতো দেখতে API-তে মান client-এই বসিয়ে দেয় (client-side emulation), তাই নিজের driver-এর নথি পড়ুন। Injection-এর সাধারণ পিতৃ-শ্রেণি CWE-74, আর ব্যবহারিক নির্দেশনা আছে OWASP Cheat Sheet Series-এ। PostgreSQL প্রস্তুত statement-এর জন্য custom আর generic plan-এর মধ্যে বাছাই করতে পারে, যা কর্মক্ষমতার প্রশ্ন, গঠনের নয়।

পরীক্ষা ও ইন্টারভিউতে:

  • Parameterized query কেন কাজ করে? গঠন আগে parse হয়, মান পরে আসে। তাই মান গঠন বদলাতে পারে না।
  • ORDER BY-তে placeholder চলে না কেন? কলামের নাম গঠনের অংশ। Allowlist map দিয়ে কোডের লেখা টুকরো বসান।
  • Validation থাকলে bind লাগে? হ্যাঁ। Validation বাড়তি স্তর। অনেক বৈধ মানে বিশেষ চিহ্ন থাকে।
  • Second-order কী? নিরাপদে জমা মান পরে জোড়া লেখায় ঢুকলে যে বাগ হয়। সব query-তে bind.
  • Least privilege কি injection থামায়? না, ক্ষতির সীমা টানে।

১০ · AI-এর যুগে injection

AI কোড লিখে দেয় দ্রুত, কিন্তু পুরোনো ইন্টারনেটের টিউটোরিয়াল থেকেও শেখে। তাই জোড়া লেখার কোড এখনো বেরিয়ে আসতে পারে, আর দেখতে একদম স্বাভাবিক লাগে। আর আজকের অ্যাপে AI নিজেই একটি নতুন interpreter, যার ঝুঁকি prompt injection পর্ব ৫১-এ দেখবেন।

AI-এর সাধারণ ভুলকেন সমস্যাযাচাই
f"..." বা + দিয়ে SQL বানানোমিশ্র পথBind-এ পাল্টান, O'Brien দিয়ে test করুন
“ORM আছে, তাই নিরাপদ” বলাRaw query-র ঝুঁকি উপেক্ষিতtext, raw খুঁজে দেখুন
Error ঠিক করতে replace("'", "")ডেটা নষ্ট করে, বাগ রয়ে যায়Escaping নয়, bind চান
shell=True সুবিধার জন্যShell-এর ব্যাকরণ ফিরে আসেArgument array বা library চান
বানানো CVE বা ভুল OWASP ক্রমিকভুল প্রমাণের ওপর সিদ্ধান্তমূল উৎস পড়ুন
“এই কোড নিরাপদ” নিশ্চিত সুরে বলাঅতিরিক্ত আত্মবিশ্বাসনিজে review আর SAST চালান

AI-কে সাহায্য নিন প্রতিরক্ষার জন্য, নিজের কোডের ওপর। গোপন তথ্য, আসল connection string বা গ্রাহকের ডেটা মুছে তারপর পেস্ট করুন:

তুমি একজন secure code reviewer. নিচের কোডটি আমার নিজের প্রকল্পের। এখান থেকে connection string আর গোপন তথ্য সরিয়ে দিয়েছি। [এখানে কোড] ১. প্রতিটি query, shell call আর template-এ চিহ্নিত করো: ইনপুট কি লেখার সাথে মিশছে? ২. প্রতিটি সমস্যার পাশে parameter binding, allowlist বা argument array দিয়ে নিরাপদ পরিবর্তন দাও, শুধু বদলানো লাইন। ৩. Dynamic কলাম বা সাজানোর জন্য allowlist map লেখো। ৪. O'Brien-এর মতো নিরীহ মান দিয়ে কয়েকটি regression test প্রস্তাব করো। ৫. কোনো হামলার লেখা দেবে না। CWE নম্বর বললে তা MITRE-তে যাচাইযোগ্য হতে হবে, নিশ্চিত না হলে বলবে।

AI-এর কোড মেনে নেওয়ার আগে

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১SQL injection-এর প্রতিকার প্রায় তিন দশক ধরে জানা। তবু এটি কেন বড় ঘটনার কারণ হয়ে চলেছে?

SQL injection প্রকাশ্য আলোচনায় আসে ১৯৯৮ সালের দিকে, Phrack ম্যাগাজিনের একটি লেখায়। Parameter binding-এর API-ও বহুদিন ধরে আছে। তবু OWASP Top 10:2025-এও Injection পঞ্চম স্থানে, যদিও আগের ২০২১ সংস্করণের তৃতীয় স্থান থেকে নেমেছে। নামাটি একটি ভালো খবর, কারণ আধুনিক framework নিরাপদ পথকে ডিফল্ট করেছে।

দুটি প্রকাশিত ঘটনা ছবিটি পরিষ্কার করে। যুক্তরাজ্যের তথ্য কর্তৃপক্ষ ICO ২০১৬ সালে TalkTalk-কে ৪ লাখ পাউন্ড জরিমানা করে। প্রকাশিত প্রতিবেদন অনুযায়ী অধিগ্রহণ করা পুরোনো ওয়েব পাতায় SQL injection বাগ ছিল, এবং পূর্ব সতর্কতার সংকেত থাকা সত্ত্বেও ব্যবস্থা নেওয়া হয়নি। ২০২৩ সালে MOVEit Transfer নামের ফাইল-স্থানান্তর পণ্যে CVE-2023-34362 নামের SQL injection zero-day ধরা পড়ে, CISA-র advisory AA23-158A অনুযায়ী যা ব্যাপকভাবে ডেটা চুরিতে ব্যবহৃত হয়।

টিকে থাকার কারণগুলো প্রযুক্তিগত যত, সাংগঠনিকও তত। প্রথমত, পুরোনো কোড: অধিগ্রহণ করা বা ভুলে যাওয়া পাতা কেউ পড়ে না। দ্বিতীয়ত, জোড়া লেখাই সহজ, আর সাধারণ পরীক্ষায় কাজ করে। তৃতীয়ত, রিপোর্ট আর অনুসন্ধান পাতায় dynamic query-র প্রকৃত চাহিদা আছে। চতুর্থত, একটি বড় product-এ একটি মাত্র ভুল জায়গাই যথেষ্ট, আর রক্ষককে সব জায়গায় সঠিক থাকতে হয়। পঞ্চমত, নিরাপদ পথ জানা থাকলেও সময়ের চাপে সবাই তা মানেন না। ষষ্ঠত, দায়িত্ব প্রায়ই অস্পষ্ট থাকে: একটি vendor-এর পণ্যের ভেতরের কোড ক্রেতা দেখতে পান না, অথচ ক্ষতি তাঁরই গ্রাহকের। তাই শুধু নিজের কোড নয়, কেনা পণ্যের ঝুঁকি আর patch-এর গতিও নকশার অংশ।

মূল উপলব্ধি: জানা সমাধান আর প্রয়োগ করা সমাধান এক নয়। ভালো রক্ষক নিরাপদ পথকে সবচেয়ে সহজ পথ বানান, আর পুরোনো কোডের তালিকাও রাখেন।

প্র ০২টেলিফোন, SQL আর AI prompt — তিনটিতে একই সমস্যা কোথায়, আর AI-তে bind কেন নেই?

টেলিফোনের পুরোনো নকশায় নিয়ন্ত্রণের সুর আর কথা একই লাইনে যেত। নেটওয়ার্ক তাই শোনা সুরকে আদেশ ভাবত। প্রকৌশলীরা ইনপুট ছাঁকার বদলে আসল প্রতিকার করেন: নিয়ন্ত্রণের জন্য আলাদা নেটওয়ার্ক। ১৯৭০-এর দশক থেকে এই out-of-band ধারণা ছড়ায়, আর পরে SS7 সাধারণ রূপ নেয়। তখন গ্রাহকের লাইনের কোনো সুর আর নিয়ন্ত্রণের পথে পৌঁছাত না।

SQL-এ prepared statement হুবহু একই পদক্ষেপ। গঠন যায় এক পথে, মান আরেক পথে। প্রতিকারটি শক্তিশালী কারণ এটি কোনো শনাক্তকরণ নয়, একটি কাঠামোগত নিশ্চয়তা। SQL-এর ব্যাকরণ আনুষ্ঠানিক, তাই “এটি মান, ওটি আদেশ” সীমা সঠিকভাবে টানা যায়।

এখন একটি বড় ভাষা মডেল ভাবুন। সে নির্দেশ আর যাচাই করার ডকুমেন্ট, দুটিই পড়ে সাধারণ লেখা হিসেবে। মডেলের ভেতরে এমন কোনো আনুষ্ঠানিক parser নেই, যা বলবে এই শব্দগুলো শুধু ডেটা। তাই একটি ওয়েবপাতা বা ইমেইলের ভেতরের লেখা মডেলের আচরণে প্রভাব ফেলতে পারে। এই ঝুঁকি prompt injection, যা পর্ব ৫১-এ আসবে। OWASP-এর LLM অ্যাপের ঝুঁকির তালিকাতেও এটি শীর্ষে রাখা হয়েছে। আজ পর্যন্ত এর bind-এর মতো সম্পূর্ণ সমাধান নেই। তাই রক্ষকরা SQL-এর পুরোনো শিক্ষার অন্য স্তরগুলো ব্যবহার করেন: AI-এর হাতে সীমিত অধিকার, গুরুত্বপূর্ণ কাজে মানুষের অনুমোদন, আউটপুটকে অবিশ্বস্ত ইনপুট ধরা। লক্ষ করুন, এগুলো সবই ক্ষতি সীমিত করার স্তর, আর SQL-এর least privilege ও allowlist-এর সাথে এদের মিল স্পষ্ট। মডেলের দেওয়া লেখা সরাসরি SQL বা shell-এ পাঠালে পুরোনো injection আবার ফিরে আসে, তাই সেখানেও bind আর argument array লাগবে।

মূল উপলব্ধি: নিয়ন্ত্রণ আর ডেটা আলাদা করা গেলে সমস্যাটি নকশাতেই শেষ হয়। আলাদা করা না গেলে ক্ষতি সীমিত রাখার স্তরগুলোই ভরসা।

প্র ০৩SQL-এ escaping শেষ উপায়, অথচ পর্ব ৪০-এর HTML-এ output encoding প্রধান প্রতিরক্ষা। এটি কি স্ববিরোধ?

দেখতে স্ববিরোধ, আসলে নীতি একই। প্রশ্নটি “escaping ভালো না খারাপ” নয়। প্রশ্ন হলো: আলাদা পথ আছে কি না, আর চিহ্ন বদলানোর কাজটি কে করছে।

SQL-এ ডেটাবেস নিজেই আলাদা পথ দেয়। মান protocol-এর আলাদা অংশে যায়। সেখানে হাতে escaping করা মানে একটি নিশ্চিত পথ রেখে অনিশ্চিত পথে যাওয়া, যা নির্ভর করে encoding আর server-এর সেটিংয়ের মতো খুঁটিনাটির ওপর।

HTML-এ ব্রাউজার একটি ফাইল পায়, যেখানে গঠন আর লেখা একসাথে। HTML-এর ফর্ম্যাটে “এই অংশটি আলাদা পথে আসবে” বলার উপায় সেভাবে নেই। তাই সেখানে encoding অপরিহার্য। তবু ভালো অভ্যাস হাতে encoding করা নয়। আধুনিক template engine প্রসঙ্গ দেখে ডিফল্টে encoding করে, ফলে কাজটি একটি পরীক্ষিত যন্ত্রের হাতে। ব্রাউজারে textContent-এর মতো API আসলে bind-এরই সমতুল্য: লেখা শুধু লেখা থাকে, parser-এর কাছে যায় না। LDAP-এও একই চিত্র: অনেক library-তে bind নেই, তাই RFC 4515 মেনে তৈরি encoder-ই প্রধান পথ।

একটি বিচারের ক্রম এখান থেকে আসে। প্রথমে আলাদা পথের API খুঁজুন। না পেলে ওই interpreter-এর জন্য তৈরি library দিন। হাতে লেখা replace একেবারে শেষে, কার্যত কখনো নয়। আর encoding হতে হবে সঠিক প্রসঙ্গে। HTML-এর encoder SQL-এ বা shell-এ কাজে আসে না, আর একই ইনপুট HTML-এর লেখার অংশে আর attribute-এর ভেতরে ভিন্ন নিয়ম চায়। এ কারণেই “সব ইনপুট একবার ঢোকার সময় পরিষ্কার করে নেব” ধারণাটি ভুল। ঢোকার সময় আমরা জানিই না, মানটি পরে কোন interpreter-এ যাবে। তাই সুরক্ষা বসাতে হয় ব্যবহারের জায়গায়, যেখানে প্রসঙ্গ জানা।

মূল উপলব্ধি: নিয়ম মুখস্থ নয়, কারণ মনে রাখুন। আলাদা পথ থাকলে সেটিই নিন। না থাকলে প্রসঙ্গ-সচেতন, পরীক্ষিত encoder. প্রতিটি ক্ষেত্রেই লক্ষ্য একটাই: ইনপুট যেন ডেটাই থাকে।

প্র ০৪WAF বসানো আছে। তাহলে পুরোনো কোডের জোড়া query ঠিক করতে সময় খরচ কেন?

ব্যবস্থাপকের প্রশ্নটি অযৌক্তিক নয়। WAF দ্রুত বসে, কোড না বদলেই কিছু সুরক্ষা দেয়। সমস্যা হলো, সে একটি ভিন্ন জিনিস পরিমাপ করে।

WAF দেখে অনুরোধের লেখা, আর অনুমান করে এটি বিপজ্জনক কি না। ঠিক এই পদ্ধতিটিই একটি denylist: পরিচিত নকশা খোঁজা। আপনার query-তে মান কীভাবে বসছে, তা সে জানে না। ফলে দুই দিকেই ভুল থাকে। O'Brien-এর মতো সৎ ইনপুট আটকে যেতে পারে। আবার তার জানা নকশার বাইরে কিছু এলে ধরা না-ও পড়তে পারে। Second-order বাগ তো WAF-এর দেখার পথেই আসে না, কারণ ক্ষতিকর ব্যবহার ঘটে ভেতরের কাজে।

সংখ্যাও একই কথা বলে। ১০,০০,০০০ অনুরোধে ২০টি আসল চেষ্টা থাকলে, ৯০% ধরা আর ০.১% ভুল-সতর্কতা নিয়ে precision হয় প্রায় ১.৮%. দল alert-এ ক্লান্ত হয়ে পড়ে, যা নিজেই ঝুঁকি। আর নিয়ম কড়া করলে সৎ গ্রাহক আটকান, তাতে ব্যবসার ক্ষতি। এই টানাপোড়েন কোড ঠিক হলে থাকে না, কারণ তখন WAF-এর ভুল আর গুরুতর ফল আনে না।

তবে WAF-কে বাদ দেওয়ার কথা নয়। Log4Shell-এর সময় বহু দল WAF নিয়ম বসিয়ে patch করার সময় কিনেছিল, পরে সেই নিয়ম এড়ানোর নতুন উপায়ও প্রকাশিত হয়। শিক্ষাটি পরিষ্কার: WAF জরুরি ব্যান্ডেজ, অস্ত্রোপচার নয়। তাই সিদ্ধান্তটি হওয়া উচিত দুটিই: এখনই নিয়ম, আর নির্দিষ্ট সময়ের মধ্যে কোড ঠিক করা, যাতে virtual patch স্থায়ী হয়ে না যায়।

মূল উপলব্ধি: বাইরের জাল ঝুঁকি কমায়, দুর্বলতা সরায় না। কোড বদলালে সমস্যা শেষ হয়, WAF-এ শুধু তার গতি কমে।

প্র ০৫“আমার কোডে তো SQL নেই।” Log4Shell আর Shellshock দেখে এই নিশ্চিন্ততা কতটা ঠিক?

Injection-এর কঠিন রূপগুলো সেখানে থাকে, যেখানে কেউ interpreter আছে বলে ভাবেন না। ২০১৪ সালের Shellshock (CVE-2014-6271) ছিল Bash-এর একটি বাগ: environment variable-এ রাখা নির্দিষ্ট লেখা প্রক্রিয়ার সময় আদেশ হিসেবে চলে যেত। ডেভেলপার হয়তো নিজে shell-এ কিছু লেখেননি, কিন্তু CGI-এর মতো পুরোনো ওয়েব ব্যবস্থার নিচে shell ছিল, আর অনুরোধের তথ্য সেই পরিবেশে পৌঁছাত।

২০২১ সালের Log4Shell (CVE-2021-44228) আরও শিক্ষণীয়। একটি log লেখা সবচেয়ে নিরীহ কাজ মনে হয়। কিন্তু Log4j 2 log বার্তার ভেতরের বিশেষ lookup প্রক্রিয়া করত। ফলে ব্যবহারকারীর লেখা একটি log বার্তা গঠনের মতো পড়া হলো। অসংখ্য system ঝুঁকিতে পড়েছিল, কারণ library-টি অদৃশ্য গভীরতায় ছিল, অনেক দল জানতই না তারা এটি ব্যবহার করে।

রক্ষকের জন্য এখানে তিনটি কাজ। প্রথমত, নিজের system-এর interpreter-এর তালিকা বানান: ডেটাবেস, shell, template, expression language, XML parser, regex, এমনকি log library. দ্বিতীয়ত, অপ্রয়োজনীয় ক্ষমতা বন্ধ রাখুন। যে feature দরকার নেই, তা ডিফল্টে বন্ধ, যা পর্ব ৩-এর attack surface কমানোর নীতি। Log4j-এর পরবর্তী সংস্করণে ঠিক এই পথই নেওয়া হয়। তৃতীয়ত, dependency-র হিসাব রাখুন, যাতে একটি library-তে সমস্যা ধরা পড়লে জানতে পারেন আপনি কোথায় আক্রান্ত। পর্ব ৪৮-এর SBOM সেই কাজের জন্য।

এখান থেকে নকশার একটি প্রশ্ন আসে: নতুন library নেওয়ার সময় জিজ্ঞেস করুন, এটি কি লেখার ভেতরের বিশেষ চিহ্ন প্রক্রিয়া করে? করলে সেই সুবিধা বন্ধ করা যায় কি? ঘটনা ঘটলে কোথায় log আর কোন সংস্করণ দেখবেন, তাও পর্ব ৩২-এর প্রক্রিয়ায় আগে থেকে ঠিক রাখুন।

মূল উপলব্ধি: ডেটা যেখানে কোনো ভাষায় পড়া হয়, সেখানেই একটি interpreter আছে। প্রশ্নটি “আমার কোডে SQL আছে কি না” নয়, “আমার stack-এর কে কে লেখাকে আদেশ হিসেবে পড়ে?”

অনুশীলন

সব অনুশীলন নিজের কোড বা খেলনা ডেটায়।

  1. WAF-এর হিসাব (numerical): একটি কাল্পনিক অনলাইন বাজারে দিনে ২০,০০,০০০ অনুরোধ, এর মধ্যে আসল চেষ্টা ৪০টি। নিয়ম ৮০% চেষ্টা ধরে, আর সাধারণ অনুরোধের ০.০৫%-এ ভুল সতর্কতা দেয়। Precision কত? প্রতিটি alert দেখতে ২ মিনিট লাগলে দিনে কত ঘণ্টা লাগবে?

    সত্য ধরা = 40 × 0.80 = ৩২টি। ভুল সতর্কতা = 1,999,960 × 0.0005 = ৯৯৯.৯৮, প্রায় ১,০০০টি। মোট alert ≈ ১,০৩২টি।

    Precision = 32 ÷ 1031.98 ≈ ০.০৩১, অর্থাৎ প্রায় ৩.১%. সময় = 1031.98 × 2 ≈ ২,০৬৪ মিনিট, মানে প্রায় ৩৪.৪ ঘণ্টা। একদিনে একজনের পক্ষে অসম্ভব, তাই alert-কে ব্যাখ্যাসহ অগ্রাধিকার দিতে হবে, আর আসল সমাধান কোডেই।

  2. কোড সমালোচনা (design বা critique): একটি নিজের প্রকল্পের ফাংশন: অনুরোধ থেকে city আর sort নেয়, "... WHERE city = '" + city + "' ORDER BY " + sort লিখে SQL বানায়, admin অ্যাকাউন্টে চালায়, আর ভুল হলে str(e) ব্যবহারকারীকে দেখায়। কতগুলো সমস্যা, আর প্রতিটির সমাধান কী?
    • city জোড়া: WHERE city = ? আর bind.
    • sort জোড়া: bind চলবে না। Allowlist map দিয়ে স্থির টুকরো বসান, অচেনা হলে খারিজ।
    • admin অ্যাকাউন্ট: শুধু দরকারি টেবিলে SELECT অধিকারসহ আলাদা অ্যাকাউন্ট।
    • str(e): সাধারণ বার্তা আর correlation ID, বিস্তারিত server log-এ।
    • বাড়তি: city-র দৈর্ঘ্য সীমা, আর O'Brien-ধরনের regression test.
  3. ফল অনুমান করুন (predict the output): খেলনা SQLite টেবিল items(name, price)-এ এই ক্রমে তিনটি সারি ঢোকানো হলো: ('chal', 80), ('dal', 140), ('tel', 60). SELECT name FROM items ORDER BY ? চালানো হলো 'price' bind করে, তারপর SELECT name FROM items ORDER BY price. দুটি আউটপুট কী হবে?

    Python 3.14 আর SQLite 3.50-এ স্থানীয়ভাবে চালিয়ে পাওয়া ফল:

    bound: [('chal',), ('dal',), ('tel',)]
    fixed: [('tel',), ('chal',), ('dal',)]

    প্রথমটিতে 'price' একটি text মান, প্রতিটি সারির জন্য একই ধ্রুবক। তাই কার্যত কোনো সাজানোই হয়নি, আর কোনো error-ও নেই। SQL-এর নিয়মে এখানে ক্রম নিশ্চিত নয়, এই ক্রমটি SQLite-এর এই ক্ষেত্রের আচরণ। দ্বিতীয়টিতে price কলাম, তাই ৬০, ৮০, ১৪০ ক্রমে আসে।

  4. নিজের কোডে খোঁজ (command task): নিজের একটি ছোট প্রকল্পে অংশ ৯-এর grep আদেশগুলো চালান। প্রতিটি ফলকে তিন ভাগে ভাগ করুন: নিরাপদ, ঠিক করতে হবে, বুঝতে হবে। তারপর একটি পাতার জন্য একটি regression test লিখুন।

    অনেক ফল ভুল সতর্কতা হবে, যেমন লগ বার্তা জোড়া, বা শুধু স্থির লেখা জোড়া। প্রতিটির ক্ষেত্রে জিজ্ঞেস করুন: চলকটি কোথা থেকে এল? কোডে লেখা ধ্রুবক হলে নিরাপদ, ইনপুট বা ডেটাবেসের মান হলে bind করুন। আর grep যা দেখেনি, যেমন একাধিক লাইনে ভাঙা জোড়া লেখা, সেগুলোর জন্য SAST লাগবে।

    Test-এর ধারণা: O'Brien নামে একটি গ্রাহক তৈরি করুন, পাতায় খুঁজুন, আর assert করুন উত্তর 200 আর সেই সারিটিই আসছে। তারপর [[STRUCTURE-CHANGE]] দিয়ে খুঁজে assert করুন 200 আর ০ সারি, 500 নয়।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি parameterized query-র ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. প্রোগ্রামার placeholder সহ স্থির SQL লেখেন
  2. ডেটাবেস লেখাটি parse করে গঠন ঠিক করে
  3. ডেটাবেস খোঁজার পথ (query plan) তৈরি করে
  4. ইনপুটের মান আলাদা পথে ঘরে bind হয়
  5. Query চলে আর সারি ফেরত আসে

Injection দুর্বলতার মূল কারণ কোনটি?

  • ইনপুট খুব লম্বা হওয়া
  • অবিশ্বস্ত ডেটা আর আদেশ একই পথে মিশে interpreter-এ পৌঁছানো
  • ডেটাবেস encrypted না থাকা
  • HTTPS ব্যবহার না করা

ব্যবহারকারী কলাম বেছে নিয়ে পণ্য সাজাতে চান। সবচেয়ে ভালো নকশা কোনটি?

  • ORDER BY ? লিখে কলামের নাম bind করা
  • ইনপুট থেকে বিশেষ চিহ্ন মুছে SQL-এ জুড়ে দেওয়া
  • অনুমোদিত চাবি থেকে কোডে লেখা কলামের নামে map করা, অচেনা চাবি খারিজ করা
  • সরাসরি জুড়ে দিয়ে WAF-এর ওপর ভরসা করা

একটি stored procedure-কে অ্যাপ bind দিয়ে ডাকে, কিন্তু procedure-এর ভেতরে প্যারামিটার জুড়ে SQL লেখা বানিয়ে চালানো হয়। অবস্থা কী?

  • Injection-এর ঝুঁকি আছে, কারণ ভেতরে মান আবার SQL লেখার সাথে মিশছে
  • নিরাপদ, কারণ সব stored procedure নিরাপদ
  • নিরাপদ, কারণ ডেটাবেস নিজেই ভেতরের জোড়া লেখা bind করে নেয়
  • প্রতিবারই syntax error হবে, তাই কোনো ঝুঁকি নেই

একটি নাম bind হয়ে নিরাপদে জমা হলো। পরে একটি রিপোর্টের কোড সেই নাম ডেটাবেস থেকে পড়ে SQL লেখায় জুড়ে দিল, আর রিপোর্ট ভেঙে গেল। এটি কোন ধারণা?

  • Log injection
  • Command injection
  • IDOR
  • Second-order injection

একটি অ্যাপকে সংকুচিত ফাইল বানাতে হবে। ইনপুট থেকে ফাইলের নাম আসে। কোন নকশা সবচেয়ে ভালো ক্রমে সাজানো?

  • Shell-এ পুরো আদেশ লেখা, ইনপুট থেকে বিশেষ চিহ্ন মুছে
  • প্রথমে ভাষার library, দরকার হলে shell ছাড়া argument array, দুটিতেই পথ যাচাই
  • Shell-এ চালানো, আর সব আদেশ log করা
  • Root হিসেবে চালানো, যাতে অধিকারের error না আসে

ওয়েব অ্যাপের ডেটাবেস অ্যাকাউন্টকে শুধু দরকারি টেবিলের অধিকার দিলে injection-এর ক্ষেত্রে কী হয়?

  • Injection আর সম্ভব হয় না
  • Parameterized query-র আর দরকার থাকে না
  • বাগ থেকেও গেলে সম্ভাব্য ক্ষতির পরিসর ছোট থাকে
  • Error বার্তায় schema ফাঁস নিজে থেকে বন্ধ হয়ে যায়

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব শব্দ এখানে একসাথে:

মনে রাখুন

  1. Injection-এর মূল: ডেটা আর আদেশ একই পথে। সমাধান: পথ আলাদা করা।
  2. Parameterized query প্রধান প্রতিরক্ষা: গঠন আগে, মান পরে। ORM-এর raw query আর procedure-এর ভেতরটাও দেখুন।
  3. কলাম আর সাজানোর দিক allowlist map থেকে আসে। Escaping শেষ উপায়।
  4. Validation বাড়তি স্তর, allowlist ভালো, কিন্তু bind-এর বিকল্প নয়। Least privilege আর নিরাপদ error ক্ষতি সীমিত করে।
  5. Shell-এ argument array, আর একই নীতি NoSQL, LDAP, template আর log-এ। ডেটাবেসের মানও bind. WAF, query নজরদারি, review আর SAST ধরার স্তর।

পরের পর্বে: Injection-এর একটি রূপ সরাসরি ব্রাউজারে ঘটে। পর্ব ৪০-এ দেখবেন XSS আর তার প্রতিরক্ষা: output encoding আর Content Security Policy.

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান