개발일지/Node.js

SQL Injection 방지 - 권장방식에는 다 이유가 있지..

E-room 2024. 1. 31. 13:14
728x90

nodejs express 공부를 하고 있는데, 쿼리를 작성하던 중 문득 생각이 들었다.

`
SELECT * FROM users
WHERE user_id = ?
`

 

mariadb 모듈에는 query 함수가 있는데,

첫 번째 인자로 쿼리 문자열 받고,

두 번째 인자로 쿼리의 플레이스홀더(? 부분)에 해당하는 부분을 배열 형태로 받는다.

세 번째 인자로 콜백함수를 받는다.

 

여기서 ${} 형태로 작성하면 가독성과 유지보수 측면에서 더 좋지 않나? 라는 생각이 들었다.

`
SELECT * FROM users
WHERE user_id = ${userId}
`

 

이런 식으로 작성하고 두 번째 인자를 생략하면 정상적으로 작동된다.

 

하지만 또 한편으로 드는 생각이 굳이 첫 번째 방식으로 작성하고 두 번째 인자로 배열을 받는 이유가 있지 않을까? 라는 생각이 들었다.

 

그래서 검색을 좀 해보니 두번째 방식으로 작성을 하게 되면 SQL Injection 공격에 취약해진다고 한다.

 

역시 개발자들은 항상 코드에 이유가 있다.

 

그래서 테스트를 해보았다.

1. ${변수}

// 소스코드
login1 = (email, password) => {
  return new Promise((resolve, reject) => {
    const query = `
      SELECT * FROM users u
      WHERE email = '${email}' AND password = '${password}';
      `;
    db.query(query, (error, data) => {
      if (error) reject(error);
      else resolve(data[0]);
    });
  });
}

// email과 password에 아래와 같이 입력
login1("test1", "' OR '1' = '1")
    .then(result => console.log(result));
    
/* 결과
{
    "user_id": 1,
    "email": "test1",
    "name": "네임",
    "password": "$2b$10$qJ3/h/W4yiLLygW6.1xn8uI6xpbLY1/h6EpX./PkydaZ6tBd3AakC",
    "created_at": "2024-01-21T13:04:43.000Z",
    "modified_at": "2024-01-31T02:56:35.000Z"
}
*/

 

위 코드를 실행하면 데이터베이스에서는 아래와 같은 쿼리가 실행된다.

-- 실제 데이터베이스에서 실행되는 쿼리
SELECT * FROM users u
WHERE email = 'test1' AND password = '' OR '1'  = '1';

-- 위 쿼리는 결국 아래 쿼리와 같다
SELECT * FROM users u

DB에서 해당 쿼리를 실행하였을 경우 users테이블의 모든 데이터가 조회된다.

코드에서 data[0]를 통해 첫번째 데이터만 응답하도록 했기 때문에 한건만 조회가 되었지만, 
모든 데이터에서 첫번째 데이터가 반환되게 된다.
 

2. 플레이스 홀더(공식 권장 방식)

// 소스코드
const login2 = (email, password) => {
    return new Promise((resolve, reject) => {
        const query = `
        SELECT * FROM users u
        WHERE email = ? AND password = ?;
        `;
        db.query(query, [email, password], (error, data) => {
            if (error) reject(error);
            else resolve(data[0]);
        });
    });
}

// email과 password에 아래와 같이 입력
login2("test1", "' OR '1' = '1")
    .then(result => console.log(result));
    
/* 결과
undefined
*/

 

위 코드를 실행하면 데이터베이스에서는 아래와 같은 쿼리가 실행된다.

-- 실제 데이터베이스에서 실행되는 쿼리
SELECT * FROM users u
WHERE email = 'test1' AND password = "'' OR '1'  = '1'";

SQL 인젝션을 위해 입력한 password부분이 하나의 문자열로 데이터베이스에서 실행이 된다.

그러므로 조건에 맞는 데이터가 없기 때문에 빈 배열을 가져오게 되고, 빈 배열의 첫 번째 데이터를 리턴하기 때문에 undefined가 반환된다.

 

mariadb의 공식문서를 살펴보면

https://github.com/mariadb-corporation/mariadb-connector-nodejs/blob/master/documentation/callback-api.md

첫번째 줄을 살펴보면 To avoid SQL Injection attacks, 라고 되어 있는 것을 볼 수 있다.

SQL Injection attacks를 피하기 위해 라고 친절하게 적어놓았다.

 

결론

물론, 비밀번호의 경우 데이터베이스에 암호화해서 저장을 하고, 암호화 모듈에서 제공하는 함수를 이용해서 비교를 하기 때문에 위 쿼리처럼 WHERE절에 email AND password를 사용해서 로그인을 진행하는 경우는 드물다.

 

그렇다면 첫번째 방법을 사용해도 되는 거냐?

당연히 절대 안 된다.

위에서는 password에 조건문만 집어넣었지만 DELETE, DROP TABLE, UPDATE 등 다양한 쿼리를 사용해서 공격을 시도할 수 있다.

(이 경우에도 생각해야 할 것들이 있지만 주제와 맞지 않으므로 pass)

 

어쨌든 해커가 공격할 여지를 남겨주는 것은 좋은 방법이 아니다.

 

권장하는 방법을 쓰자.

 

전체 코드

const mariadb = require('mariadb/callback');

const DB_HOST = '호스트';
const DB_PORT = '포트';
const DB_USER = '유저';
const DB_PASSWORD = '비밀번호';
const DB_DATABASE = '데이터베이스명';

const db = mariadb.createConnection({
    host: DB_HOST,
    port: DB_PORT,
    user: DB_USER,
    password: DB_PASSWORD,
    database: DB_DATABASE,
});

// ${} 방식
const login1 = (email, password) => {
    return new Promise((resolve, reject) => {
        const query = `
        SELECT * FROM users u
        WHERE email = '${email}' AND password = '${password}';
        `;
        db.query(query, (error, data) => {
            if (error) reject(error);
            else resolve(data[0]);
        });
    });
}
login1("test1", "' OR '1' = '1")
    .then(result => console.log(result));

// ? 방식
const login2 = (email, password) => {
    return new Promise((resolve, reject) => {
        const query = `
            SELECT * FROM users u
            WHERE email = ? AND password = ?;
            `;
        db.query(query, [email, password], (error, data) => {
            if (error) reject(error);
            else resolve(data[0]);
        });
    });
}
login2("test1", "' OR '1' = '1")
    .then(result => console.log(result));
728x90