TOOLS · PHP ROUTER SECURITY
들어오는 문은
index.php 하나만
열어 두세요
라우터 방식의 보안은 한 문장으로 요약됩니다. 밖에서 들어오는 문은 index.php 하나뿐이고, 나머지 PHP 파일은 주소로 직접 열리지 않아야 해요. 이 한 문을 지키는 방법과, 문을 통과한 뒤에 지켜야 할 것들을 정리했어요.
- 3단계 검사
- 7가지 팁
- 15개 배포 전 체크
원칙
모든 요청은 한 문으로
프론트 컨트롤러(Front Controller) 방식에서는 웹서버가 실제 파일(CSS, JS, 이미지)만 그대로 내주고, 나머지 요청은 전부 index.php로 보냅니다. 라우터가 허용 목록에 있는 주소만 실행하니, 주소 하나하나가 보안 검사를 거치는 구조가 됩니다.
반대로 /app/admin/delete.php처럼 내부 파일을 주소로 직접 열 수 있으면 라우터의 로그인·권한 검사를 건너뛸 수 있어요. "직접 URL 접속 차단"이 중요한 이유가 이것입니다.
빨간 선은 요청이 막히는 곳입니다. 웹서버 단계에서 한 번(팁 2), 라우터에서 한 번(팁 3), 미들웨어에서 한 번 더 걸러집니다.
팁 1
웹 루트는 public 폴더만
가장 확실한 차단은 설정이 아니라 위치입니다. 웹서버의 문서 루트(DocumentRoot, Nginx의 root)를 public/으로 잡으면, 그 밖에 있는 파일은 주소가 아예 없습니다. 설정을 실수해도 열릴 길이 없어요.
project/
├─ app/ 컨트롤러, 모델, 뷰 (주소 없음)
├─ config/ DB 접속 정보, 설정 (주소 없음)
├─ storage/ 로그, 캐시, 세션 파일 (주소 없음)
├─ vendor/ Composer 패키지 (주소 없음)
├─ .env 비밀 키 (주소 없음)
└─ public/ ← 여기만 웹 루트
├─ index.php 유일한 입구 (라우터)
├─ assets/ CSS, JS, 이미지
└─ uploads/ 업로드 파일 (PHP 실행 금지)호스팅 사정으로 웹 루트를 바꿀 수 없다면 두 가지를 같이 씁니다.
- 내부 폴더마다 접근 차단 설정을 둡니다 (팁 2).
- 내부 PHP 파일 맨 위에 "입구를 거쳐 왔는지" 확인하는 한 줄을 넣습니다.
// public/index.php 맨 위
define('APP_ENTRY', true);
// app/ 아래 모든 PHP 파일 맨 위
if (!defined('APP_ENTRY')) { http_response_code(404); exit; }두 번째 방법은 파일마다 잊지 않고 넣어야 해서 예비용입니다. 가능하면 폴더 구조로 해결하는 게 우선입니다.
팁 2
직접 URL 접속 차단
웹서버에서 네 가지를 막습니다. 실제 파일이 아닌 요청은 전부 index.php로 보내기, 숨김 파일과 민감한 확장자 막기, 폴더 목록 끄기, 업로드 폴더에서 PHP 실행 금지.
| 막을 것 | 예시 주소 | 열리면 생기는 일 |
|---|---|---|
| 내부 PHP 파일 | /app/admin/delete.php | 라우터의 로그인·권한 검사를 건너뜀 |
| 숨김 파일 | /.env, /.git/config, /.htaccess | DB 비밀번호·키 노출, 소스 전체 복원 |
| 민감한 확장자 | /backup.sql, /config.php.bak, /error.log | DB 덤프, PHP 원본이 글자로 그대로 보임 |
| 폴더 목록 | /uploads/ | 올라간 파일 목록이 그대로 보임 |
| 업로드 폴더의 PHP | /uploads/shell.php | 누군가 올린 파일이 서버에서 실행됨 |
Apache (.htaccess, public/ 안)
# 1) 폴더 목록 끄기
Options -Indexes
# 2) 숨김 파일과 민감한 확장자 막기
<FilesMatch "^\.|\.(env|ini|log|sql|bak|old|swp|inc|lock|md)$">
Require all denied
</FilesMatch>
RedirectMatch 404 /\.git
# 3) 실제 파일이 아니면 전부 index.php로
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]# 4) 업로드 폴더에서 스크립트 실행 금지
<FilesMatch "\.(php|phtml|phar|php\d)$">
Require all denied
</FilesMatch>Nginx (server 블록)
root /var/www/project/public;
autoindex off;
# 숨김 파일과 민감한 확장자
location ~ /\. { deny all; return 404; }
location ~* \.(env|ini|log|sql|bak|old|swp|inc|lock)$ { deny all; return 404; }
# 업로드 폴더에서 PHP 실행 금지 (아래 php 블록보다 위에)
location ^~ /uploads/ {
location ~ \.(php|phtml|phar)$ { deny all; return 404; }
}
# 실제 파일이 아니면 전부 index.php로
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# PHP는 index.php 하나만 실행
location = /index.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php-fpm.sock;
}
location ~ \.php$ { return 404; }Nginx에서는 마지막 두 블록이 핵심입니다. index.php만 실행하고 다른 .php 주소는 전부 404를 돌려주니, 실수로 public 안에 PHP 파일을 두어도 주소로는 열리지 않아요. PHP-FPM 소켓 경로는 서버마다 다르니 지금 설정에 맞춰 주세요.
막은 주소에는 403보다 404를 돌려주는 편이 낫습니다. 403은 "거기 뭔가 있다"는 뜻이 되기 때문이에요.
팁 3
라우터 코드
문을 하나로 모았으면, 그 문을 지키는 라우터가 허술하지 않아야 합니다. 다섯 가지를 지킵니다.
- 허용 목록(화이트리스트)으로만 라우팅 — 등록한 주소만 실행하고, 나머지는 모두 404.
- 주소로 파일을 고르지 않기 —
include $_GET['page'] . '.php'같은 코드는?page=../../config/db로 서버 파일을 열 수 있게 합니다(로컬 파일 포함, LFI). 주소는 항상 미리 정한 핸들러에 연결합니다. - HTTP 메서드 구분 — 저장·삭제는 POST로만. GET으로 삭제가 되면 이미지 태그 하나(
<img src="/admin/post/5/delete">)로도 지워집니다. 다른 메서드는 405. - 권한 검사는 라우트 묶음 단위로 —
/admin으로 시작하는 주소는 라우터에서 한 번에 막습니다. 페이지마다 검사를 넣으면 하나는 꼭 빠집니다. - 에러 화면 통일 — 없는 주소와 권한 없는 주소가 같은 404를 보여 주면 관리자 경로를 찾기 어렵습니다.
// public/index.php
define('APP_ENTRY', true);
require __DIR__ . '/../app/bootstrap.php';
$method = $_SERVER['REQUEST_METHOD'];
$path = rawurldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
$path = '/' . trim($path, '/');
// 1) 허용 목록: [메서드, 주소 패턴] => 핸들러
$routes = [
['GET', '#^/$#', [HomeController::class, 'index']],
['GET', '#^/(\d+)$#', [PostController::class, 'show']],
['GET', '#^/admin/dashboard$#', [AdminController::class, 'dashboard']],
['POST', '#^/admin/post/(\d+)/delete$#', [AdminController::class, 'delete']],
];
// 4) 관리자 주소는 라우터에서 한 번에 보호
if (str_starts_with($path, '/admin') && $path !== '/admin/login' && !is_admin()) {
not_found(); // 로그인 페이지로 보내도 됨. 관리자 경로 존재를 숨기려면 404
}
$allowed = [];
foreach ($routes as [$m, $pattern, $handler]) {
if (preg_match($pattern, $path, $args)) {
if ($m !== $method) { $allowed[] = $m; continue; }
if ($m === 'POST') verify_csrf(); // 팁 4
array_shift($args);
[$class, $fn] = $handler;
(new $class)->$fn(...$args);
exit;
}
}
// 3) 주소는 맞는데 메서드가 틀리면 405, 아예 없으면 404
if ($allowed) { http_response_code(405); header('Allow: ' . implode(', ', $allowed)); exit; }
not_found();str_starts_with는 PHP 8.0부터 있습니다. 더 낮은 버전이면 strpos($path, '/admin') === 0을 씁니다. /admin 검사는 /administrator 같은 주소도 걸리니, 다른 공개 페이지가 /admin으로 시작하지 않는지 확인하세요.
관리자 경로 숨기기는 보안이 아닙니다. /admin을 /x7k-manage로 바꾸면 무작위 스캔이 줄어드는 정도입니다. 진짜 보안은 위의 권한 검사와 팁 5의 로그인 보호입니다. 관리자가 한 명이고 고정 IP가 있다면 웹서버에서 /admin을 그 IP에만 여는 것도 효과가 큽니다.
팁 4
입력은 의심하고, 출력은 감싸기
문을 통과한 요청도 내용은 믿지 않습니다. 댓글, 방명록, 검색창, 업로드처럼 밖에서 들어오는 값이 있는 곳이 대상입니다.
| 위험 | 막는 법 | 한 줄 요약 |
|---|---|---|
| SQL 주입 | PDO prepared statement | 값을 쿼리 문자열에 이어 붙이지 않는다 |
| XSS (스크립트 주입) | 출력할 때 htmlspecialchars | 저장할 때가 아니라 화면에 낼 때 감싼다 |
| CSRF (대신 요청) | POST마다 CSRF 토큰 | 내 사이트 폼에서 온 요청만 받는다 |
| 악성 업로드 | 내용 검사 + 새 이름 + 실행 금지 폴더 | 파일 이름과 확장자는 믿지 않는다 |
SQL: 값은 항상 자리표시자로
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false, // 진짜 prepared statement 사용
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
// ❌ $pdo->query("SELECT * FROM posts WHERE id = $_GET[id]");
$stmt = $pdo->prepare('SELECT * FROM posts WHERE id = ?');
$stmt->execute([$id]);정렬 기준처럼 자리표시자를 못 쓰는 곳(ORDER BY 열 이름)은 허용 목록으로 고릅니다. in_array($sort, ['created_at', 'hits'], true)처럼요.
XSS: 화면에 낼 때 감싸기
function e(?string $s): string {
return htmlspecialchars($s ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
<p><?= e($comment['body']) ?></p>
<input value="<?= e($q) ?>">댓글이나 방명록처럼 누구나 쓰는 글은 반드시 e()로 내보냅니다. 관리자 에디터로 쓴 본문처럼 HTML을 그대로 보여야 하는 곳은 관리자만 쓸 수 있는지 확인하고, 외부 입력이 섞일 수 있다면 HTML Purifier 같은 정화 도구를 거칩니다.
CSRF: POST에는 토큰
function csrf_token(): string {
return $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
}
function verify_csrf(): void {
$sent = $_POST['_csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!hash_equals($_SESSION['csrf'] ?? '', $sent)) { http_response_code(419); exit; }
}
?>
<form method="post" action="/admin/post/5/delete">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
</form>??=는 PHP 7.4부터 쓸 수 있습니다. 비교는 === 대신 hash_equals를 써야 시간 차이로 토큰을 추측하는 공격을 막을 수 있어요.
파일 업로드: 이름·확장자는 믿지 않기
- 크기 제한을 먼저 확인합니다 (
$_FILES['f']['size'], php.ini의upload_max_filesize). - 파일 내용으로 형식을 확인합니다.
(new finfo(FILEINFO_MIME_TYPE))->file($tmp)이image/jpeg,image/png,image/webp중 하나여야 합니다. - 원래 이름은 버리고 새 이름을 만듭니다.
bin2hex(random_bytes(16)) . '.webp'처럼요. - 이미지는 다시 인코딩해서 저장합니다. 이미지 안에 숨긴 코드가 떨어져 나갑니다.
- 저장 폴더에서는 PHP가 실행되지 않게 합니다 (팁 2).
팁 5
로그인과 세션
관리자 화면이 있는 사이트에서 가장 많이 두드려지는 곳이 로그인입니다. 여섯 가지를 확인합니다.
- 비밀번호는
password_hash로만 저장 — 확인은password_verify. md5, sha1, 직접 만든 암호화는 쓰지 않습니다. - 로그인 성공 직후 세션 ID 교체 —
session_regenerate_id(true). 공격자가 미리 심어 둔 세션을 그대로 쓰지 못하게 합니다. - 세션 쿠키 옵션 — 자바스크립트에서 못 읽게(HttpOnly), https에서만(Secure), 다른 사이트에서 따라오지 않게(SameSite).
- 로그인 시도 횟수 제한 — 같은 IP나 계정에서 5번 틀리면 15분 막기처럼. 비밀번호를 무한히 대입하는 공격을 멈춥니다.
- 실패 메시지는 하나로 — "아이디가 없습니다"와 "비밀번호가 틀렸습니다"를 구분하면 어떤 아이디가 있는지 알려 주는 셈입니다.
- 로그아웃은 POST로, 세션을 완전히 비우기 —
$_SESSION = []; session_destroy();
// bootstrap.php — session_start() 보다 먼저
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true, // https 적용 후
'httponly' => true,
'samesite' => 'Lax',
]);
ini_set('session.use_strict_mode', '1');
session_start();
// 로그인 처리
$stmt = $pdo->prepare('SELECT id, password_hash FROM admins WHERE login_id = ?');
$stmt->execute([$loginId]);
$admin = $stmt->fetch();
if (!$admin || !password_verify($password, $admin['password_hash'])) {
record_failed_attempt($loginId); // 시도 횟수 기록
exit('아이디 또는 비밀번호를 확인해 주세요.');
}
session_regenerate_id(true);
$_SESSION['admin_id'] = $admin['id'];배열 형태의 session_set_cookie_params는 PHP 7.3부터 쓸 수 있습니다. 관리자가 한 명뿐이라면 로그인에 2단계 인증(OTP)을 붙이는 것도 부담 대비 효과가 큽니다.
팁 6
서버 설정과 보안 헤더
에러와 버전 정보는 공격자에게 지도가 됩니다. 운영 서버에서는 보여 주지 말고 로그에만 남깁니다.
; php.ini (운영 서버)
display_errors = Off ; 화면에 에러 출력 안 함
log_errors = On ; 대신 로그로
error_log = /var/www/project/storage/logs/php-error.log
expose_php = Off ; 응답 헤더에서 PHP 버전 숨김화면에 뜨는 PHP 에러에는 파일 경로, DB 이름, 때로는 쿼리까지 들어 있습니다. 에러 로그는 웹 루트 밖(storage/)에 둡니다.
보안 헤더
| 헤더 | 값 | 막는 것 |
|---|---|---|
X-Content-Type-Options | nosniff | 브라우저가 파일 형식을 마음대로 추측해 스크립트로 실행하는 것 |
X-Frame-Options | SAMEORIGIN | 다른 사이트가 내 페이지를 투명하게 감싸 클릭을 훔치는 것 |
Referrer-Policy | strict-origin-when-cross-origin | 다른 사이트로 나갈 때 전체 주소(관리자 경로 등)가 새는 것 |
Strict-Transport-Security | max-age=31536000 | http로 몰래 되돌리는 공격 (https가 완전히 자리 잡은 뒤에) |
Content-Security-Policy | 사이트별로 직접 작성 | 허용하지 않은 스크립트 실행 (XSS 2차 방어) |
// bootstrap.php
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('Referrer-Policy: strict-origin-when-cross-origin');HSTS는 한 번 보내면 브라우저가 기간 동안 http로는 아예 접속하지 않습니다. https 인증서가 자동 갱신되는지 확인한 뒤에 짧은 값(max-age=300)부터 시작하세요.
CSP는 효과가 가장 크지만, 광고(AdSense)나 외부 스크립트를 쓰는 사이트에서는 허용할 주소가 많아 한 번에 걸면 화면이 깨집니다. Content-Security-Policy-Report-Only로 먼저 걸어 어떤 주소가 막히는지 본 뒤에 적용하는 게 안전합니다.
팁 7
배포와 비밀 정보
코드를 잘 짜도 배포할 때 딸려 올라간 파일 하나로 다 새어 나갈 수 있습니다.
주석: PHP 주석과 HTML 주석은 다르다
<?php // 이 주석은 서버에서만 보인다. 브라우저로 안 감 ?>
<!-- 이 주석은 그대로 브라우저로 간다. '페이지 소스 보기'로 누구나 볼 수 있다 -->- PHP 주석(
//,/* */)은 지울 필요가 없습니다. 서버 밖으로 나가지 않아요. - 뷰 파일의 HTML 주석
<!-- -->은 그대로 나갑니다. "관리자 메뉴 잠시 숨김", "TODO: 테스트 계정 test/1234" 같은 메모를 HTML 주석에 남기지 마세요. 화면에서 숨기려면<?php /* ... */ ?>로 감싸면 됩니다. - 어느 쪽이든 주석에 비밀번호·키를 적지 않습니다. 서버 안에만 있어도 백업·Git·유출된 소스로 퍼집니다.
- JS·CSS를 빌드 도구로 압축한다면 주석은 자동으로 빠집니다. 다만 소스맵(
.map) 파일을 운영 서버에 올리면 주석을 포함한 원본이 복원되니 올리지 않습니다.
비밀 정보는 코드 밖으로
- DB 비밀번호, API 키는
.env나config/안의 설정 파일에 두고, 이 파일은 웹 루트 밖에 둡니다 (팁 1). .env는.gitignore에 넣고, 대신 값이 빈.env.example을 올립니다.- 한 번이라도 Git에 올라간 키는 커밋을 지워도 기록에 남습니다. 키 자체를 새로 발급하는 게 유일한 해결책입니다.
서버에 남아 있으면 안 되는 파일
| 파일 | 왜 위험한가 |
|---|---|
.git/ 폴더 | 소스 전체와 커밋 기록을 내려받을 수 있음 |
*.bak, *.old, index.php~, .swp | .php가 아니라서 실행되지 않고 원본 코드가 글자로 보임 |
*.sql, *.zip 백업 | DB·사이트 전체를 내려받을 수 있음 |
phpinfo.php, test.php | 서버 설정·경로·환경변수가 그대로 보임 |
composer.json, composer.lock | 쓰는 패키지와 버전(알려진 취약점)이 드러남 |
배포는 가능하면 "필요한 파일만 골라 올리는" 방식으로 합니다. FTP로 폴더를 통째로 올리면 위 파일들이 같이 올라가기 쉽습니다. Composer를 쓴다면 운영 서버에는 composer install --no-dev --optimize-autoloader로 개발용 패키지를 빼고 설치합니다.
배포 전
배포 전 체크리스트
주소창에 직접 넣어 보면 확인되는 것이 대부분입니다. 진짜 도메인에서 한 번씩 열어 보고, 404가 나오면 통과입니다.
넣으면 항목마다 바로 열어 보는 링크가 생겨요. 폴더 이름이 다르면 내 프로젝트에 맞는 주소로 바꿔서 열어 보세요.
주소로 확인
코드·설정으로 확인
마지막으로 브라우저 개발자 도구의 Network 탭에서 아무 페이지나 열고, 응답 헤더에 X-Powered-By: PHP/8.x가 보이지 않는지, 보안 헤더 3개(팁 6)가 붙어 있는지 확인합니다.
AI에게
AI에게 점검 맡기는 요청문
위 팁을 그대로 점검 기준으로 넘기면 됩니다. "보안 점검해 줘"만 쓰면 일반론이 돌아오니, 구조와 범위를 정해 줍니다.
이 프로젝트는 PHP + 프론트 컨트롤러(라우터) 구조예요. 모든 요청은 public/index.php로 들어오고, 웹서버는 Apache예요. 웹 루트를 public/으로 나누지 못해서, 내부 폴더는 웹서버 설정과 입구 확인 상수로 막고 있어요. 아래 항목을 기준으로 보안 점검을 해 주세요. 코드를 고치기 전에 항목별로 [통과 / 문제 / 확인 불가]를 표로 먼저 보여 주세요. 1. index.php 말고 주소로 직접 실행되는 PHP 파일이 있는지 2. 라우터가 허용 목록으로만 동작하는지, 주소·파라미터로 include 하는 곳이 없는지 3. /admin 주소가 라우터에서 한 번에 권한 검사를 받는지 4. 저장·삭제가 POST로만 되고 CSRF 토큰을 검사하는지 5. SQL에 값을 문자열로 이어 붙인 곳이 있는지 (PDO prepared statement 사용 여부) 6. 사용자 입력을 출력하는 곳에 htmlspecialchars가 빠진 곳이 있는지 7. 파일 업로드가 내용 검사·새 이름·실행 금지 폴더를 지키는지 8. 로그인: password_hash, session_regenerate_id, 쿠키 옵션, 시도 횟수 제한 9. 뷰 파일의 HTML 주석과 코드 주석에 비밀번호·키·내부 경로가 있는지 10. 웹서버 설정에서 숨김 파일, 백업 확장자, 폴더 목록이 막혀 있는지 문제가 있는 항목은 고칠 파일과 줄을 알려 주고, 한 번에 하나씩 고쳐 주세요. 웹서버 설정·php.ini처럼 제가 직접 바꿔야 하는 것은 바꿀 내용만 알려 주세요.
마지막 두 줄이 중요합니다. 보안 수정을 한꺼번에 맡기면 라우터나 로그인처럼 사이트 전체에 영향을 주는 부분이 한 번에 바뀌어서, 무엇이 어디를 깨뜨렸는지 찾기 어렵습니다.
함께 보면 좋은 도구: 배포 전 보안 점검이 들어 있는 바이브 코딩 백엔드·배포 용어 60, 고치다 에러가 나면 에러 → 디버깅 프롬프트.