hyunsu blog

홈강좌Rust 실무 16편 중 16편째

Rust 와 보안 2 — 웹 API 를 지키는 설정: 크기·시간 제한과 SQL 주입

목차 · 3
  1. 01크기 제한과 시간 제한
  2. 02SQL 주입은 Rust 도 막지 못한다
  3. 03실습 — 웹 API 보안 확인 목록

Rust 로 만든 웹 API 도 설정과 습관으로 지켜야 한다. axum 의 DefaultBodyLimit 로 큰 요청을 413 으로 막고, tower-http 의 TimeoutLayer 로 오래 걸리는 요청을 408 로 끊는다. SQL 을 문자열로 이어 붙이면 Rust 에서도 주입 공격에 뚫린다는 것을 실행해 보고, 매개변수로 고친다.

01 크기 제한과 시간 제한

공격자가 아주 큰 본문을 보내거나, 아주 느리게 끝나는 요청을 많이 보내면 서버의 메모리와 연결이 바닥난다. 둘 다 제한을 두는 것이 기본이다.

toml
[package]
name = "guarded-api"
version = "0.1.0"
edition = "2024"

[dependencies]
axum = "=0.8.9"
tokio = { version = "=1.54.1", features = ["rt-multi-thread", "macros", "net", "time", "io-util"] }
tower-http = { version = "=0.7.1", features = ["timeout"] }
rust
use axum::extract::DefaultBodyLimit;
use axum::routing::{get, post};
use axum::Router;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tower_http::timeout::TimeoutLayer;

async fn upload(body: String) -> String {
    format!("received {} bytes", body.len())
}

async fn slow() -> &'static str {
    tokio::time::sleep(Duration::from_secs(3)).await;
    "finally"
}

fn app() -> Router {
    Router::new()
        .route("/upload", post(upload))
        .route("/slow", get(slow))
        .layer(DefaultBodyLimit::max(1024))
        .layer(TimeoutLayer::with_status_code(
            axum::http::StatusCode::REQUEST_TIMEOUT,
            Duration::from_secs(1),
        ))
}

async fn send(method: &str, path: &str, body: &str) -> String {
    let mut s = tokio::net::TcpStream::connect("127.0.0.1:7878").await.unwrap();
    let req = format!(
        "{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
        body.len()
    );
    s.write_all(req.as_bytes()).await.unwrap();
    let mut res = String::new();
    s.read_to_string(&mut res).await.unwrap();
    let status = res.lines().next().unwrap_or("").replace("HTTP/1.1 ", "");
    let text = res.split("\r\n\r\n").nth(1).unwrap_or("");
    format!("{method} {path} ({} bytes) -> {status} {text}", body.len())
}

#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:7878").await.unwrap();
    tokio::spawn(async move { axum::serve(listener, app()).await.unwrap() });

    println!("{}", send("POST", "/upload", &"a".repeat(500)).await);
    println!("{}", send("POST", "/upload", &"a".repeat(5000)).await);
    println!("{}", send("GET", "/slow", "").await);
}
text
     Running `target/debug/guarded-api`
POST /upload (500 bytes) -> 200 OK received 500 bytes
POST /upload (5000 bytes) -> 413 Payload Too Large Failed to buffer the request body: length limit exceeded
GET /slow (0 bytes) -> 408 Request Timeout 
  • .layer(DefaultBodyLimit::max(1024)) 는 본문을 1024바이트까지만 받는다. 500바이트는 200, 5000바이트는 413(너무 큼)이다. 핸들러는 불리지도 않았다. axum 은 따로 정하지 않아도 기본 제한(2MB)이 있지만, API 마다 실제로 필요한 크기로 줄이는 편이 낫다.
  • .layer(TimeoutLayer::…(408, 1초)) 는 1초 안에 끝나지 않은 요청을 끊고 408(시간 초과)로 답한다. 3초짜리 /slow 는 끝까지 기다리지 않았다.
  • .layer 로 붙이는 것이 4편 표의 tower 미들웨어다. 로그(TraceLayer)·압축·CORS 도 같은 방식으로 붙인다.

02 SQL 주입은 Rust 도 막지 못한다

SQLite 를 메모리에 띄워 두 사람의 메모를 넣고, 주인 이름으로 그 사람의 메모만 찾는다. 하나는 format! 으로 SQL 을 이어 붙이고, 하나는 매개변수를 쓴다.

toml
[package]
name = "users"
version = "0.1.0"
edition = "2024"

[dependencies]
rusqlite = { version = "=0.41.1", features = ["bundled"] }
rust
use rusqlite::{Connection, params};

fn setup() -> Connection {
    let db = Connection::open_in_memory().unwrap();
    db.execute_batch(
        "CREATE TABLE notes (owner TEXT, title TEXT);
         INSERT INTO notes VALUES ('ferris', 'shopping list');
         INSERT INTO notes VALUES ('corro', 'private diary');",
    )
    .unwrap();
    db
}

fn find_unsafe(db: &Connection, name: &str) -> Vec<String> {
    let sql = format!("SELECT title FROM notes WHERE owner = '{name}'");
    let mut stmt = db.prepare(&sql).unwrap();
    stmt.query_map([], |row| row.get(0)).unwrap().map(|r| r.unwrap()).collect()
}

fn find_safe(db: &Connection, name: &str) -> Vec<String> {
    let mut stmt = db.prepare("SELECT title FROM notes WHERE owner = ?1").unwrap();
    stmt.query_map(params![name], |row| row.get(0)).unwrap().map(|r| r.unwrap()).collect()
}

fn main() {
    let db = setup();
    let attack = "nobody' OR '1'='1";

    println!("unsafe, normal: {:?}", find_unsafe(&db, "ferris"));
    println!("unsafe, attack: {:?}", find_unsafe(&db, attack));
    println!("safe,   normal: {:?}", find_safe(&db, "ferris"));
    println!("safe,   attack: {:?}", find_safe(&db, attack));
}
text
     Running `target/debug/users`
unsafe, normal: ["shopping list"]
unsafe, attack: ["shopping list", "private diary"]
safe,   normal: ["shopping list"]
safe,   attack: []

공격 입력 nobody' OR '1'='1 을 넣자, 이어 붙인 쪽은 SQL 이 WHERE owner = 'nobody' OR '1'='1' 이 되어 다른 사람의 메모(private diary)까지 모두 돌려줬다. 매개변수(?1 + params![name]) 쪽은 입력 전체를 이름 값으로만 다뤄서 아무것도 찾지 못했다.

이것은 메모리 문제가 아니라 15편의 "로직" 쪽 문제라 컴파일러가 모른다. format! 으로 만든 문자열을 SQL 에 넣는 코드가 보이면 그 자리에서 고친다. sqlx 의 query! 매크로처럼 매개변수를 강제하고 컴파일할 때 SQL 을 검사하는 도구를 쓰면 실수할 자리가 더 줄어든다.

03 실습 — 웹 API 보안 확인 목록

확인방법
본문 크기DefaultBodyLimit::max(…) 를 API 에 맞게
처리 시간TimeoutLayer. DB·외부 API 호출에도 따로 시간 제한
SQL매개변수만. format! 으로 만든 SQL 금지
입력 검증핸들러 맨 앞에서 길이·범위·형식(14편)
금액·수량 계산checked_*(15편)
오류 응답내부 정보(SQL·경로·스택)를 응답에 싣지 않는다. 자세한 것은 로그로만(14편의 AppError 처럼 문장을 직접 정한다)
비밀 값토큰·비밀번호를 로그에 찍지 않는다. Debug 로 구조체를 통째로 찍을 때 특히 조심
CORS허용할 출처만 적는다. 모두 허용(*)하지 않는다
인증·속도 제한로그인·토큰 검사, IP 당 요청 수 제한(미들웨어 크레이트를 쓴다)
의존성버전 고정·RustSec 권고 확인(12편)

자기 API 에 큰 본문(예: 10MB)과 오래 걸리는 요청을 직접 보내 보고, 어떤 상태 코드가 오는지 확인하자. 제한이 없으면 첫 두 줄부터 넣는다.

이것으로 Rust 실무 16편을 마친다. 고르기 전에 알 것(1~3편), 많이 쓰는 크레이트(4~8편), 잘 쓰는 습관(9~10편), AI 와 함께 쓰기(11~12편), 웹 서비스와 보안(13~16편)이었다. 입문부터 다시 보려면 Rust 입문·중급 목차에서 시작하면 된다.