Skip to content

Commit

Permalink
Merge pull request #30 from tonybaloney/django_sql
Browse files Browse the repository at this point in the history
Django SQL Injection techniques
  • Loading branch information
tonybaloney authored Jan 31, 2020
2 parents a9d4c36 + 3042968 commit 8c2c9fc
Show file tree
Hide file tree
Showing 12 changed files with 315 additions and 10 deletions.
4 changes: 4 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Release History

## 1.4.0

* Added [DJG101](doc/checks/DJG101.md) Using quoted, parametrized literal will bypass Django SQL Injection protection

## 1.3.0

* Added [TRY100](doc/checks/TRY100.md) check for try..except..pass statements
Expand Down
11 changes: 3 additions & 8 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ plugins {
}

group 'org.tonybaloney.security'
version '1.3.0'
version '1.4.0'

repositories {
mavenCentral()
Expand Down Expand Up @@ -36,14 +36,9 @@ intellij {

patchPluginXml {
changeNotes """
<h2>1.3.0</h2>
<h2>1.4.0</h2>
<ul>
<li>Added TRY100 check for try..except..pass statements</li>
<li>Added TRY101 check for try..except..continue statements</li>
<li>Added AST100 check for assert usage outside of a test</li>
<li>Added NET100 check unspecified binding </li>
<li>Added PAR100 check for host key bypass in paramiko ssh client usage</li>
<li>Added OS100 check calls to `os.chmod()` for dangerous POSIX permissions</li>
<li>Added DJG101 : Using quoted, parametrized literal will bypass Django SQL Injection protection</li>
</ul>
"""
}
Expand Down
4 changes: 2 additions & 2 deletions doc/bandit.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ List of bandit plugins that have equivalent checks in pycharm-security:
|-----------------------------------------------------------|----------------------------------|----------------------------|
| app_debug.py (Flask Debug) | Yes | [FLK100](checks/FLK100.md) |
| asserts.py | Yes | [AST100](checks/AST100.md) |
| crypto_request_no_cert_validation.py (requests no verify) | Yes, for both httpx and requests | [RQ100](checks/RQ100.md)) |
| django_sql_injection.py | No | |
| crypto_request_no_cert_validation.py (requests no verify) | Yes, for both httpx and requests | [RQ100](checks/RQ100.md) |
| django_sql_injection.py | Yes | [DJG101](checks/DJG101.md) |
| django_xss.py | No | |
| exec.py | Yes | [EX100](checks/EX100.md) |
| general_bad_file_permissions.py | Yes | [OS100](checks/OS100.md) |
Expand Down
57 changes: 57 additions & 0 deletions doc/checks/DJG101.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# DJG101

This check looks for ways that Django SQL Injection protection is being bypassed, by using quoted parameters.

The check looks at the following use cases:

* Use of `RawSQL()` constructor directly
* Use of `cursor.execute()`
* Use of `raw()` on a `Manager` instance

Whilst the methods support parametrized queries, if the `%s` value is quoted with single-quotes, the value is still vulnerable to SQL injection.

## Example

The first example is using the RawSQL constructor directly and annotating a query set:

```python
from django.db.models.expressions import RawSQL

qs.annotate(val=RawSQL("select col from sometable where othercol = '%s'", (someparam,))) # this is bad!
```

Another example is using the `raw()` method on a manager to filter results, exposing SQL injection:

```python
from django import things
from .models import User

def my_view(self):
User.objects.raw("SELECT * FROM myapp_person WHERE last_name = '%s'", [lname]) # this is also bad!
```

Cursors can also be exploited using the same technique:

```python
from django.db import connection

def my_custom_sql(self):
with connection.cursor() as cursor:
cursor.execute("UPDATE bar SET foo = 1 WHERE baz = %s", [self.baz])
cursor.execute("SELECT foo FROM bar WHERE baz = '%s'", [self.baz])
row = cursor.fetchone()
return row
```

## Fixes

Remove the quotations from the string values:

```python
("UPDATE bar SET foo = 1 WHERE baz = %s", [self.baz]) # good
("UPDATE bar SET foo = 1 WHERE baz = '%s'", [self.baz]) # bad!
```

## See Also

* [Official Documentation](https://docs.djangoproject.com/en/3.0/ref/models/expressions/#django.db.models.expressions.RawSQL)
1 change: 1 addition & 0 deletions doc/checks/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Django Web Framework
:maxdepth: 1

DJG100 : Setting `DEBUG = True` in a `settings.py` file (assumed Django project settings) <DJG100>
DJG101 : Using quoted, parametrized literal will bypass Django SQL Injection protection <DJG101>
DJG200 : Django middleware is missing `CsrfViewMiddleware`, which blocks cross-site request forgery <DJG200>
DJG201 : Django middleware is missing `XFrameOptionsMiddleware`, which blocks clickjacking. <DJG201>

Expand Down
1 change: 1 addition & 0 deletions src/main/java/security/Checks.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ object Checks {
val SubprocessShellCheck = CheckType("PR100", "Calling subprocess commands with shell=True can leave the host shell open to local code execution or remote code execution attacks.")
val TempfileMktempCheck = CheckType("TMP100", "The way that tempfile.mktemp creates temporary files is insecure and leaves it open to attackers replacing the file contents. Use tempfile.mkstemp instead.")
val DjangoDebugModeCheck = CheckType("DJG100", "Running Django in Debug mode is highly insecure and should only be used for local development purposes.")
val DjangoRawSqlCheck = CheckType("DJG101", "Using quoted, parametrized literal will bypass Django SQL Injection protection.")
val DjangoCsrfMiddlewareCheck = CheckType("DJG200", "Django middleware is missing CsrfViewMiddleware, which blocks cross-site request forgery.")
val DjangoClickjackMiddlewareCheck = CheckType("DJG201", "Django middleware is missing XFrameOptionsMiddleware, which blocks clickjacking.")
val InsecureHashAlgorithms = CheckType("HL100", "MD4, MD5, SHA, and SHA1 hashing algorithms have cryptographically weak algorithms and should not be used for obfuscating or protecting data.")
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/security/helpers/ImportValidators.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package security.helpers

import com.jetbrains.python.psi.PyFile

object ImportValidators {
fun hasImportedNamespace(file: PyFile, match: String) : Boolean {
val imports = file.importBlock ?: return false
if (imports.isEmpty()) return false
for (imp in imports){
if (imp.fullyQualifiedObjectNames.isEmpty()) continue
if (imp.fullyQualifiedObjectNames.first() == match) return true
if (imp.fullyQualifiedObjectNames.first().startsWith("$match.")) return true
}
return false
}
}
51 changes: 51 additions & 0 deletions src/main/java/security/validators/DjangoRawSqlInspection.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package security.validators

import com.intellij.codeInspection.LocalInspectionToolSession
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.psi.PsiElementVisitor
import com.jetbrains.python.inspections.PyInspection
import com.jetbrains.python.inspections.PyInspectionVisitor
import com.jetbrains.python.psi.PyCallExpression
import com.jetbrains.python.psi.PyFile
import com.jetbrains.python.psi.PyStringLiteralExpression
import security.Checks
import security.helpers.ImportValidators.hasImportedNamespace

class DjangoRawSqlInspection : PyInspection() {
val check = Checks.DjangoClickjackMiddlewareCheck;

override fun getStaticDescription(): String? {
return check.getDescription()
}

override fun buildVisitor(holder: ProblemsHolder,
isOnTheFly: Boolean,
session: LocalInspectionToolSession): PsiElementVisitor = Visitor(holder, session)

private class Visitor(holder: ProblemsHolder, session: LocalInspectionToolSession) : PyInspectionVisitor(holder, session) {
val methodNames = arrayOf("RawSQL", "raw", "execute")
override fun visitPyCallExpression(node: PyCallExpression?) {
if (node == null) return
val calleeName = node.callee?.name ?: return
if (!listOf(*methodNames).contains(calleeName)) return

if (node.containingFile !is PyFile) return
if (!hasImportedNamespace(node.containingFile as PyFile, "django")) return

val sqlStatement = node.arguments.first() ?: return
if (sqlStatement !is PyStringLiteralExpression) return
val param = Regex("%s")
val paramMatches = param.findAll(sqlStatement.stringValue)
for (match in paramMatches){
try {
if (sqlStatement.stringValue.substring(match.range.start - 1, match.range.start) != "'") return
if (sqlStatement.stringValue.substring(match.range.last + 1, match.range.last + 2) != "'") return
} catch (oobe: StringIndexOutOfBoundsException){
// End or beginning of string, so this SQL injection technique wouldn't be possible.
return
}
holder?.registerProblem(node, Checks.DjangoRawSqlCheck.getDescription())
}
}
}
}
4 changes: 4 additions & 0 deletions src/main/java/security/validators/FlaskDebugModeInspection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import com.jetbrains.python.inspections.PyInspection
import com.jetbrains.python.inspections.PyInspectionVisitor
import com.jetbrains.python.psi.PyBoolLiteralExpression
import com.jetbrains.python.psi.PyCallExpression
import com.jetbrains.python.psi.PyFile
import com.jetbrains.python.psi.PyReferenceExpression
import security.Checks
import security.helpers.ImportValidators.hasImportedNamespace

class FlaskDebugModeInspection : PyInspection() {
val check = Checks.FlaskDebugModeCheck;
Expand All @@ -25,6 +27,8 @@ class FlaskDebugModeInspection : PyInspection() {
override fun visitPyCallExpression(node: PyCallExpression) {
val calleeName = node.callee?.name ?: return
if (calleeName != "run") return
if (node.containingFile !is PyFile) return
if (!hasImportedNamespace(node.containingFile as PyFile, "flask")) return
if (node.firstChild !is PyReferenceExpression) return
if ((node.firstChild as PyReferenceExpression).asQualifiedName().toString() != "app.run") return
val debugArg = node.getKeywordArgument("debug") ?: return
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<localInspection language="Python" enabledByDefault="true" groupName="Python Security" hasStaticDescription="true" displayName="YML100: Use of unsafe yaml load" shortName="PyyamlLoadInspection" implementationClass="security.validators.PyyamlLoadInspection" />
<localInspection language="Python" enabledByDefault="true" groupName="Python Security" hasStaticDescription="true" displayName="DJG100: Use of Django debug mode" shortName="DjangoDebugModeSettingsInspection" implementationClass="security.validators.DjangoDebugModeSettingsInspection" />
<localInspection language="Python" enabledByDefault="true" groupName="Python Security" hasStaticDescription="true" displayName="DJG200, DJG201: Missing Django Middleware" shortName="DjangoMiddlewareInspection" implementationClass="security.validators.DjangoMiddlewareInspection" />
<localInspection language="Python" enabledByDefault="true" groupName="Python Security" hasStaticDescription="true" displayName="DJG101: Using quoted, parametrized literal will bypass Django SQL Injection protection" shortName="DjangoRawSqlInspection" implementationClass="security.validators.DjangoRawSqlInspection" />
<localInspection language="Python" enabledByDefault="true" groupName="Python Security" hasStaticDescription="true" displayName="FLK100: Use of Flask debug mode" shortName="FlaskDebugModeInspection" implementationClass="security.validators.FlaskDebugModeInspection" />
<localInspection language="Python" enabledByDefault="true" groupName="Python Security" hasStaticDescription="true" displayName="RQ101: Setting verify=False when using httpx" shortName="HttpxNoVerifyInspection" implementationClass="security.validators.HttpxNoVerifyInspection" />
<localInspection language="Python" enabledByDefault="true" groupName="Python Security" hasStaticDescription="true" displayName="RQ100: Setting verify=False when using requests" shortName="RequestsNoVerifyInspection" implementationClass="security.validators.RequestsNoVerifyInspection" />
Expand Down
67 changes: 67 additions & 0 deletions src/test/java/security/helpers/ImportValidatorsTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package security.helpers

import com.intellij.openapi.application.ApplicationManager
import com.jetbrains.python.PythonFileType
import com.jetbrains.python.psi.PyFile
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import security.SecurityTestTask
import security.helpers.ImportValidators.hasImportedNamespace

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ImportValidatorsTest: SecurityTestTask() {

@BeforeAll
override fun setUp() {
super.setUp()
}

@AfterAll
override fun tearDown(){
super.tearDown()
}

@Test
fun `test empty file`(){
var code = """
""".trimIndent()
assertFalse(testHasImport(code, "django"))
}

@Test
fun `test file with no imports`(){
var code = """
x = 1
""".trimIndent()
assertFalse(testHasImport(code, "django"))
}

@Test
fun `test simple import`(){
var code = """
import django
""".trimIndent()
assertTrue(testHasImport(code, "django"))
}

@Test
fun `test from import`(){
var code = """
from django.db import DbConnection
""".trimIndent()
assertTrue(testHasImport(code, "django"))
}

private fun testHasImport(code: String, importName: String): Boolean{
var hasImport: Boolean = false
ApplicationManager.getApplication().runReadAction {
val testFile = this.createLightFile("test.py", PythonFileType.INSTANCE.language, code);
assertNotNull(testFile)
hasImport = hasImportedNamespace(testFile as PyFile, importName)
}
return hasImport
}
}
108 changes: 108 additions & 0 deletions src/test/java/security/validators/DjangoRawSqlInspectionTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package security.validators

import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import security.Checks
import security.SecurityTestTask

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DjangoRawSqlInspectionTest: SecurityTestTask() {
@BeforeAll
override fun setUp() {
super.setUp()
}

@AfterAll
override fun tearDown(){
super.tearDown()
}

@Test
fun `verify description is not empty`(){
assertFalse(DjangoRawSqlInspection().staticDescription.isNullOrEmpty())
}

@Test
fun `test quoted string`(){
var code = """
import django.db.models.expressions
x = "injectable string"
django.db.models.expressions.RawSQL("SELECT * FROM foo WHERE ID = '%s'", (x,))
""".trimIndent()
testCodeCallExpression(code, 1, Checks.DjangoRawSqlCheck, "test.py", DjangoRawSqlInspection())
}

@Test
fun `test format non quoted string`(){
var code = """
import django.db.models.expressions
x = "injectable string"
django.db.models.expressions.RawSQL("SELECT * FROM foo WHERE ID = %s", (x,))
""".trimIndent()
testCodeCallExpression(code, 0, Checks.DjangoRawSqlCheck, "test.py", DjangoRawSqlInspection())
}

@Test
fun `test some other rawsql method`(){
var code = """
RawSQL("SELECT * FROM foo WHERE ID = '%s'", (x,))
""".trimIndent()
testCodeCallExpression(code, 0, Checks.DjangoRawSqlCheck, "test.py", DjangoRawSqlInspection())
}

@Test
fun `test cursor execute with no quotes`(){
var code = """
from django.db import connection
def my_custom_sql(self):
with connection.cursor() as cursor:
cursor.execute("UPDATE bar SET foo = 1 WHERE baz = %s", [self.baz])
cursor.execute("SELECT foo FROM bar WHERE baz = %s", [self.baz])
row = cursor.fetchone()
return row
""".trimIndent()
testCodeCallExpression(code, 0, Checks.DjangoRawSqlCheck, "test.py", DjangoRawSqlInspection())
}

@Test
fun `test cursor execute with quotes`(){
var code = """
from django.db import connection
def my_custom_sql(self):
with connection.cursor() as cursor:
cursor.execute("UPDATE bar SET foo = 1 WHERE baz = %s", [self.baz])
cursor.execute("SELECT foo FROM bar WHERE baz = '%s'", [self.baz])
row = cursor.fetchone()
return row
""".trimIndent()
testCodeCallExpression(code, 1, Checks.DjangoRawSqlCheck, "test.py", DjangoRawSqlInspection())
}

@Test
fun `test model raw with quotes`(){
var code = """
from django.db import connection
from .models import User
def my_view(self):
User.objects.raw("SELECT * FROM myapp_person WHERE last_name = '%s'", [lname])
""".trimIndent()
testCodeCallExpression(code, 1, Checks.DjangoRawSqlCheck, "test.py", DjangoRawSqlInspection())
}

@Test
fun `test model raw with no quotes`(){
var code = """
from django.db import connection
from .models import User
def my_view(self):
User.objects.raw("SELECT * FROM myapp_person WHERE last_name = %s", [lname])
""".trimIndent()
testCodeCallExpression(code, 0, Checks.DjangoRawSqlCheck, "test.py", DjangoRawSqlInspection())
}
}

0 comments on commit 8c2c9fc

Please sign in to comment.