라즈베리파이에 띄운 개인 자동화 서버, 보안 점검해 보니 열려 있던 것들
TL;DR
- "나만 쓰는 도구"라도 인터넷에서 닿는 구멍이 하나라도 있으면 공개 서비스와 같은 위협을 받는다. 오토포스트는 인스타가 이미지를 가져가도록 경로 하나를 인터넷에 열어 두고 있었다.
- 점검에서 고친 것은 다섯 가지다. Funnel 이중 잠금, 예외 메시지로 새던 토큰, 남의 글이 섞인 HTML의 XSS, 이미지 다운로드 SSRF, 에이전트 토큰이 엉뚱한 호스트로 갈 수 있던 경로.
- 고치지 않고 남긴 것도 적었다. 무엇을 왜 남겼는지 적어 두는 것까지가 점검이다.
들어가며
오토포스트(postflow)는 카카오톡 테마 상품 하나를 등록하면 네이버 블로그, 포스타입, 인스타그램 글을 만들어 올리는 개인 도구다. 만든 과정은 구축기와 개선기에 적었다.
처음에는 "나만 쓰니까"라는 생각이 있었다. 서버는 집에 있는 라즈베리파이이고, 접속은 Tailscale 사설망 안에서만 한다. 그런데 구조를 다시 그려 보니 인터넷에서 닿는 입구가 하나 있었고, 그 서버는 네이버·포스타입 계정 정보와 인스타 토큰을 들고 있었다. 한 번 날을 잡아 점검했다.
구조부터 다시 그리기
| 구성 | 위치 | 닿는 곳 |
|---|---|---|
| 서버 (Spring Boot + PostgreSQL) | 라즈베리파이, Docker | Tailscale serve로 사설망에만 |
이미지 공개 주소 /media/<토큰>.jpg | 같은 서버 | Tailscale Funnel로 인터넷에 |
| Mac 에이전트 (Playwright) | 내 Mac | 서버를 폴링해 작업을 가져가 네이버·포스타입 에디터를 채움 |
| 인스타 게시 | 서버 안 @Async | Graph API 호출 |
인스타 Graph API로 게시하려면 인스타 서버가 이미지를 직접 내려받을 수 있는 공개 주소가 필요하다. 그래서 게시하는 동안만 쓰는 무작위 토큰 주소를 만들고, Funnel로 그 경로만 인터넷에 연다. 이 "경로 하나"가 점검의 출발점이었다.
1. Funnel 이중 잠금
위험
Funnel에서 어떤 경로를 열지는 Tailscale 설정이 정한다. 설정에서 /media만 열어 두었지만, 실수로 / 전체를 열면 로그인 화면과 에이전트 API가 인터넷에 그대로 드러난다. 에이전트 API는 작업을 넘길 때 계정 비밀번호까지 전달한다. 1차 잠금이 설정 하나에만 걸려 있었다.
해결
Tailscale serve는 Funnel(인터넷)로 들어온 요청에 Tailscale-Funnel-Request 헤더를 붙이고, 밖에서 같은 헤더를 보내면 지운다. 앱이 이 헤더를 보고 이미지 주소 말고는 모두 404로 돌려준다.
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class FunnelGuardFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
if (req.getHeader("Tailscale-Funnel-Request") != null && !allowed(req)) {
res.setStatus(HttpServletResponse.SC_NOT_FOUND); // 있는지조차 알려 주지 않는다
return;
}
chain.doFilter(req, res);
}
static boolean allowed(HttpServletRequest req) {
return ("GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod()))
&& req.getRequestURI().matches("/media/[A-Za-z0-9_-]{32,}\\.jpg");
}
}사설망 안에서 오는 요청에는 헤더가 없어서 평소 사용은 그대로다. 403이 아니라 404인 이유는 "여기 뭔가 있다"는 사실도 알려 주지 않기 위해서다. 배포한 뒤 휴대폰 LTE로 로그인 주소를 열어 404인지 확인했다.
2. 예외 메시지로 새던 인스타 토큰
위험
인스타 Graph API의 GET 요청은 주소에 access_token이 들어간다. 시간 초과나 DNS 오류가 나면 Spring RestClient의 예외 메시지에 요청 주소가 통째로 들어간다. 그 메시지가 로그, 토큰 연장 실패 칸, 게시 작업의 오류 칸(DB와 화면)에 그대로 저장될 수 있었다. 서버 로그를 공유하거나 화면을 캡처하는 순간 토큰이 함께 나간다.
해결
연결 오류는 원인 종류만 남긴다. 인스타가 돌려준 오류는 응답 JSON에서 사람이 읽을 메시지만 꺼낸다.
static String connectionMessage(RestClientException e) {
Throwable cause = e.getMostSpecificCause();
return "인스타 API 연결 실패(%s)".formatted(cause.getClass().getSimpleName());
}테스트는 일부러 존재하지 않는 호스트로 요청해서, 만들어진 메시지에 토큰 문자열이 들어 있지 않은지 확인한다.
3. 남의 글이 섞인 HTML의 XSS
위험
글 본문은 Mustache 템플릿에 값을 채운 뒤 CommonMark로 HTML을 만든다. 그 HTML을 상품 화면에 th:utext로 그대로 보여 주고, 에이전트는 그 HTML을 에디터에 붙여넣는다.
문제는 템플릿에 채우는 값에 내가 쓰지 않은 글이 섞여 있다는 점이다.
- AI가 쓴 소개 문단
- 프롬피드에서 가져온 제목과 요약 (다른 사람이 제보한 글)
Mustache 설정은 이스케이프를 하지 않았고, CommonMark는 원시 HTML을 통과시켰다. 그래서 제목에 <img src=x onerror=...>가 있으면 상품 화면에서 실행되고, <figure class="embed" data-url="...">가 있으면 에이전트가 그 주소로 링크 카드를 만들 수 있었다.
해결
값을 믿을 수 있는 것과 아닌 것으로 나눴다.
| 값 | 처리 |
|---|---|
| 설정 화면에서 내가 직접 쓴 HTML, 문단 간격 표시 | 그대로 |
| AI 문구, 프롬피드 제목·요약 등 그 밖의 값 | 붙여넣기용 HTML을 만들 때만 HTML 이스케이프 |
| 인스타 캡션, 텍스트 복사본 | 원래 값 (HTML이 아니므로) |
static Map<String, Object> escapeUntrusted(Map<String, Object> ctx) {
Set<String> trusted = (Set<String>) ctx.getOrDefault(TRUSTED_KEYS, Set.of());
Map<String, Object> out = new HashMap<>(ctx);
out.forEach((k, v) -> {
if (!trusted.contains(k) && !k.equals("gap")) out.put(k, escapeValue(v));
});
return out;
}여기에 CommonMark의 sanitizeUrls(true)를 켜서 [글](javascript:...) 링크 주소를 비우고, 링크 카드 주소는 엔티티를 푼 뒤 http(s)만 쓰게 했다. "텍스트로 쓰는 곳에는 원래 값, HTML로 쓰는 곳에만 이스케이프"로 나눈 이유는, 인스타 캡션에 & 같은 엔티티가 그대로 찍히는 일을 막기 위해서다.
4. 이미지 다운로드 SSRF
위험
프롬피드 글을 오토포스트로 가져올 때, 페이지에 적힌 결과 이미지 주소를 서버가 내려받는다. 그 주소는 다른 사람이 제보한 데이터에서 온다. 주소가 http://127.0.0.1:8090/...이나 사설망 주소면 서버가 자기 내부를 대신 요청하게 된다. 라즈베리파이는 사설망의 다른 기기와도 같은 망에 있다.
해결
- https만 받는다. 주소에
user@부분이 있으면 거절한다. - 호스트가 풀리는 모든 IP가 공개 주소일 때만 요청한다.
- 리다이렉트는 자동으로 따라가지 않고, 한 번씩 직접 따라가며 매번 다시 확인한다(최대 3번).
- 30MB를 넘으면 받지 않는다(메모리 보호).
InetAddress의 기본 검사만으로는 부족했다. Tailscale이 쓰는 100.64.0.0/10(CGNAT)과 IPv6 ULA, IPv4를 감싼 IPv6 주소는 직접 막아야 한다.
static boolean isPublic(InetAddress a) {
if (a.isAnyLocalAddress() || a.isLoopbackAddress() || a.isLinkLocalAddress()
|| a.isSiteLocalAddress() || a.isMulticastAddress()) return false;
byte[] b = a.getAddress();
if (b.length == 4) {
int first = b[0] & 0xff, second = b[1] & 0xff;
// 100.64.0.0/10(CGNAT·Tailscale), 0.0.0.0/8, 192.0.0.0/24, 198.18.0.0/15, 240.0.0.0/4
return !(first == 0 || (first == 100 && second >= 64 && second < 128)
|| (first == 192 && second == 0 && (b[2] & 0xff) == 0)
|| (first == 198 && (second == 18 || second == 19)) || first >= 240);
}
if ((b[0] & 0xfe) == 0xfc) return false; // fc00::/7 (ULA, Tailscale IPv6)
// ::ffff:a.b.c.d 는 안의 IPv4로 다시 본다
...
}나중에 만든 "예전 글 가져오기"에서도 같은 원칙을 썼다. 네이버 사진은 postfiles.pstatic.net만, 인스타 사진은 인스타 CDN 호스트만 받는다. 남이 정한 주소를 서버가 대신 여는 곳은 전부 허용 목록으로 좁힌다.
5. 에이전트 토큰이 다른 호스트로 갈 수 있던 경로
위험
Mac 에이전트는 작업에 적힌 파일 경로를 서버 주소 뒤에 그대로 붙여서 내려받고, 요청에 X-Agent-Token을 넣는다. 경로가 @다른호스트/파일이면 붙인 주소의 호스트가 바뀌어서 토큰이 다른 서버로 간다. 작업 데이터가 오염되면 에이전트가 토큰을 들고 남의 서버를 찾아가는 셈이다.
해결
에이전트가 받아들이는 값을 모두 형식으로 묶었다.
| 값 | 허용하는 형식 |
|---|---|
| 파일 경로 | /api/agent/files/(image/숫자 | asset/키) 만 |
| Playwright로 여는 주소 | https이고 naver.com·postype.com(하위 도메인 포함)만 |
| 블로그 ID | [A-Za-z0-9._-] 만 (주소와 발행 감지 정규식에 그대로 들어가므로) |
| 링크 카드 주소 | http(s) 만 |
서버가 믿을 수 있는 쪽이라도 받은 값을 주소에 이어 붙이는 곳은 따로 검사한다. 이번 점검에서 가장 "생각지도 못한" 경로였다.
그 밖에 손본 것
- CDN SRI: htmx, Sortable, cropper 스크립트와 스타일에
integrity와crossorigin을 붙였다. CDN 파일이 바뀌면 브라우저가 실행하지 않는다. - 운영 파일 권한: 백업 스크립트에
umask 077(덤프 600, 폴더 700). 배포할 때 서버의.env를chmod 600. 앱 컨테이너에no-new-privileges. 로컬 개발 DB는127.0.0.1에만 연다. - git 기록 점검: 전체 기록에서 토큰·키 형태의 값을 찾았고 나오지 않았다.
안정성 쪽으로 이어진 일
점검 뒤에 운영하다 생긴 일도 같은 맥락이라 적어 둔다. 인스타 게시가 진행되는 중에 배포로 서버가 다시 켜지면서 작업이 "게시 중"으로 멈춘 적이 있다. 지금은 서버가 켜질 때 끊긴 인스타 작업을 "실패, 다시 게시"로 바꾸고, 배포 스크립트는 게시가 끝날 때까지 최대 5분을 기다린다. 자세한 이야기는 개선기에 있다.
알고도 남긴 것과 이유
| 남긴 것 | 이유 | 언제 고칠까 |
|---|---|---|
| 컨테이너 non-root·read-only | 이미지 저장 폴더 소유자를 바꿔야 해서(chown) 서버에서 손으로 할 일이 필요 | 다음 서버 정리 때 user: "1000:1000"과 함께 |
| 로그인 시도 횟수 제한 | 사설망 안에서만 보이고, Funnel로는 이제 로그인 화면이 404 | Funnel 범위를 넓힐 일이 생기면 |
| 이미지 받기의 DNS 리바인딩 (확인한 뒤 접속 사이에 IP가 바뀌는 경우) | https라 인증서까지 맞춰야 해서 현실적인 위험이 낮음 | 확인한 IP로 바로 접속하는 방식으로 바꿀 때 |
게시 도중 꺼지면 남는 /media 토큰 | 무작위 192비트라 추측할 수 없고, 인스타용 변환본만 노출 | 오래된 토큰을 지우는 정리 작업을 만들 때 |
의존성은 모두 최신대였고 알려진 CVE는 찾지 못했다. 다만 스캐너는 돌리지 않았다. 이것도 남은 일이다.
확인한 것
- 서버와 에이전트 빌드, 테스트 85개 통과
- 새 테스트: Funnel 필터(헤더가 있으면
/media만 통과), 인스타 오류 메시지(토큰 없음), SSRF(사설·Tailscale·IPv6 ULA·IPv4 매핑 주소 거절, 리다이렉트 재확인), 본문 이스케이프, 에이전트 경로·호스트·블로그 ID - 배포 뒤 밖에서 로그인 주소 404, 인스타 게시 정상
회고
- 구조도를 다시 그린 게 가장 효과적이었다. "인터넷에서 닿는 곳은 어디인가"를 표로 적자 점검할 곳이 저절로 정해졌다.
- "누가 쓴 값인가"로 나누는 습관. XSS와 SSRF는 둘 다 내가 쓰지 않은 값(AI 문구, 남이 제보한 글, 작업 데이터)을 믿은 데서 나왔다.
- 잠금은 두 겹으로. 설정 하나에 기대던 Funnel 범위를 코드에서도 한 번 더 막으니, 설정 실수 하나로 모든 게 열리는 일은 사라졌다.
- 남긴 것을 적는 것까지가 점검이다. 적어 두지 않으면 "이미 확인한 것"과 "안 본 것"이 구분되지 않는다.
마치며
혼자 쓰는 도구는 편하게 만들수록 비밀을 많이 들게 된다. 계정 비밀번호, 토큰, 자동으로 글을 올리는 권한. 그래서 "나만 쓴다"는 이유로 점검을 미루기보다, 인터넷에 닿는 입구가 생기는 순간 한 번은 이렇게 들여다보는 게 맞다고 생각한다.