Rust 와 보안 2 — 웹 API 를 지키는 설정: 크기·시간 제한과 SQL 주입
Rust 로 만든 웹 API 도 설정과 습관으로 지켜야 한다. axum 의 DefaultBodyLimit 로 큰 요청을 413 으로 막고, tower-http 의 TimeoutLayer 로 오래 걸리는 요청을 408 로 끊는다. SQL 을 문자열로 이어 붙이면 Rust 에서도 주입 공격에 뚫린다는 것을 실행해 보고, 매개변수로 고친다.
01 크기 제한과 시간 제한
공격자가 아주 큰 본문을 보내거나, 아주 느리게 끝나는 요청을 많이 보내면 서버의 메모리와 연결이 바닥난다. 둘 다 제한을 두는 것이 기본이다.
[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"] }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);
} 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 을 이어 붙이고, 하나는 매개변수를 쓴다.
[package]
name = "users"
version = "0.1.0"
edition = "2024"
[dependencies]
rusqlite = { version = "=0.41.1", features = ["bundled"] }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));
} 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 입문·중급 목차에서 시작하면 된다.