| 2026-05-27T02:56:56 |
Aha00a |
1
|
0
|
= [Dev] BotDetection |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
1
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
2
|
크롤러·공격 요청을 탐지해 자동으로 차단하는 시스템 전체를 설명한다. |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
3
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
4
|
== 전체 흐름 |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
5
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
6
|
`FilterAccessLog`가 모든 요청을 가로채 아래 순서로 판정한다. |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
7
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
8
|
[[[#!Text |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
9
|
요청 도착 |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
10
|
│ |
| 2026-09-08T09:05:15 |
Aha00a |
5
|
11
|
├─[0] ipWhitelist 에 있나? ────────YES─► 아래 전부 건너뛰고 정상 처리 |
| 2026-09-08T09:05:15 |
Aha00a |
5
|
12
|
│ |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
13
|
├─[1] IpRateLimiter.isKnownBanned? ─YES─► tarpit(1~6분) + 403 |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
14
|
│ (인메모리, DB 없음) |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
15
|
│ |
| 2026-09-08T09:05:15 |
Aha00a |
5
|
16
|
├─[·] IpRateLimiter.isKnownClean? ─YES─► [2] 를 건너뛴다 (60초 TTL) |
| 2026-09-08T09:05:15 |
Aha00a |
5
|
17
|
│ |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
18
|
├─[2] IpDeny DB 조회 ──────────────YES─► ban() 캐시 + tarpit(1~6분) + 403 |
| 2026-09-08T09:05:15 |
Aha00a |
5
|
19
|
│ (0·1 이 false이고 clean 캐시도 없을 때만 실행) |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
20
|
│ |
| 2026-08-24T16:50:37 |
Aha00a |
3
|
21
|
├─[3] UriAttackDetector.isAttack? ─YES─► ban() + IpDeny 등록(async) + tarpit(1~11분) + 403 |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
22
|
│ |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
23
|
├─[4] IpRateLimiter.recordAndCheck? YES─► ban() + IpDeny 등록(async) + 즉시 403 |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
24
|
│ |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
25
|
└─[5] 정상 처리 |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
26
|
]]] |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
27
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
28
|
=== 단계별 설명 |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
29
|
|
| 2026-09-08T09:05:15 |
Aha00a |
5
|
30
|
* '''`[0]` 화이트리스트''': `AhaWiki.ipWhitelist` 에 있는 주소는 `[1]`~`[4]` 를 모두 건너뛴다. '''`IpDeny` 행이 남아 있어도 그렇다''' — 그래서 차단을 푸는 가장 빠른 방법은 행을 지우는 것이 아니라 여기에 넣는 것이다. 예전에는 `[2]` 가 이 검사보다 먼저라 한 번 등록된 주소는 화이트리스트로도 풀 수 없었고, 서버 자신의 EIP 가 두 달 막혀 있었다. 루프백 세 주소는 설정이 아니라 `logics.ApplicationConf.AlwaysWhitelisted` 에 있어서 사이트 conf 가 지울 수 없다. |
| 2026-08-24T14:30:49 |
Aha00a |
2
|
31
|
* '''`[1]` 인메모리 ban 확인''': `IpRateLimiter.knownBanned` 셋을 조회한다. DB 없음. `[2]`~`[4]`에서 차단된 IP가 재요청하면 여기서 바로 잡힌다. |
| 2026-09-08T09:58:46 |
Aha00a |
6
|
32
|
* '''`[·]` 인메모리 clean 캐시''': `knownClean` 에 있으면 `[2]` 의 DB 조회를 건너뛴다. TTL 은 `cleanTtlMs`(현재 60초). 정상 방문자가 매 요청마다 `IpDeny` 를 조회하지 않게 하는 것이 목적이다. |
| 2026-09-10T04:20:42 |
Aha00a |
7
|
33
|
* '''`[2]` IpDeny DB 조회''': 서버 재시작 전에 등록된 영구 차단 IP를 처리한다. 조회 결과가 있으면 `ban()`을 호출해 이후 요청은 `[1]`에서 처리되도록 캐시한다. 결과가 없으면 `markClean()` 으로 60초 동안 `knownClean` 에 넣는다 — `[·]` 는 여기서만 채워진다. |
| 2026-09-10T04:20:42 |
Aha00a |
7
|
34
|
* '''`[3]` UriAttackDetector''': WordPress·phpinfo 등 알려진 공격 URI를 차단한다. 탐지 즉시 `ban()`을 호출해 actor가 DB에 쓰기 전 같은 IP의 재요청을 `[1]`에서 막는다. `IpDeny` 등록 메시지는 tarpit 이 끝난 뒤(1~11분 뒤)에야 actor 로 간다 — 그 사이 재시작하면 행이 남지 않는다. `/public/`·`/assets/` 요청에서 잡힌 경우는 AccessLog 와 함께 IpDeny 등록도 건너뛴다(`shouldSkipAccessLogUri`). |
| 2026-09-10T04:20:42 |
Aha00a |
7
|
35
|
* '''`[4]` IpRateLimiter.recordAndCheck''': 고속 요청 또는 페이지만 긁는 패턴을 탐지한다. tarpit 없이 즉시 응답한다(고볼륨 봇에 tarpit을 걸면 서버 커넥션이 쌓임). 등록 메시지의 조건은 `[3]` 과 같다. |
| 2026-09-10T04:20:42 |
Aha00a |
7
|
36
|
* '''tarpit''': `after(delay, scheduler)` + 지연 응답. 스레드는 잡히지 않지만 TCP 커넥션은 열려 있다. `[1]``[2]` 와 `[3]` 에 쓰고, `[4]` 만 tarpit 없이 바로 403 이다. |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
37
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
38
|
== IpRateLimiter |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
39
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
40
|
`logics.security.IpRateLimiter` (`@Singleton`) |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
41
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
42
|
=== knownBanned |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
43
|
|
| 2026-08-24T14:30:49 |
Aha00a |
2
|
44
|
인메모리 셋(`ConcurrentHashMap.newKeySet`). 세 경로에서 추가된다. |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
45
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
46
|
* `recordAndCheck` 탐지 시 |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
47
|
* `UriAttackDetector` 탐지 시 (`FilterAccessLog`에서 직접 호출) |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
48
|
* `IpDeny` DB 조회 결과가 있을 때 (`FilterAccessLog`에서 직접 호출) |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
49
|
|
| 2026-08-24T14:30:49 |
Aha00a |
2
|
50
|
서버 재시작 시 초기화된다. 이후 요청은 `[2]` DB 조회를 통해 다시 캐시된다. |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
51
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
52
|
=== URI 분류 |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
53
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
54
|
* `WikiPage`: `/w/*` |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
55
|
* `HumanSignal`: 그 외 전부 (`/public/`, `/assets/`, `/api/links/`, `/api/me`, `/search` 등) |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
56
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
57
|
실제 브라우저는 `/w/*`와 함께 반드시 다른 요청을 섞어 보낸다. |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
58
|
순수 스크레이퍼는 `/w/*`만 요청하므로 HumanSignal이 누적되지 않는다. |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
59
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
60
|
=== 탐지 패턴 |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
61
|
|
| 2026-09-08T09:58:46 |
Aha00a |
6
|
62
|
임계값 넷은 `IpRateLimiter` 의 상수다. 괄호 안은 현재 값이다. |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
63
|
|
| 2026-09-08T09:58:46 |
Aha00a |
6
|
64
|
* '''고속 요청''': `windowMs` 안에 `/w/*` 가 `rateThreshold` 회 이상 (30초 / 30회) |
| 2026-09-08T09:58:46 |
Aha00a |
6
|
65
|
* '''페이지만 긁기''': 같은 창에서 `/w/*` 가 `botPageMin` 회 이상이고 HumanSignal 이 `botHumanSignalMin` 회 미만 (5회 / 3회) |
| 2026-09-08T09:58:46 |
Aha00a |
6
|
66
|
|
| 2026-09-05T12:16:02 |
Aha00a |
4
|
67
|
둘째 패턴은 사람이 만든 도구도 문다. 배포 헬스체크가 `/w/FrontPage` 만 3초 간격으로 폴링해서 다섯 번째에 '''자기를 차단'''한 적이 있다 — [Dev Deploying] 의 "왜 이런 모양인가" 참고. 자동 요청을 짜는 쪽에서는 `/w/` 밖의 URL 을 쓰거나 화이트리스트에 오르는 편이 낫다. |
| 2026-09-05T12:16:02 |
Aha00a |
4
|
68
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
69
|
=== cleanup |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
70
|
|
| 2026-09-10T04:20:42 |
Aha00a |
7
|
71
|
`recordAndCheck` 가 낮은 확률로 `cleanup()`을 실행해 만료된 `windows` 엔트리와 만료된 `knownClean` 엔트리를 제거한다. 확률은 그 함수 안의 `Random.nextInt` 인자(현재 1000번에 1번)다. |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
72
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
73
|
== UriAttackDetector |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
74
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
75
|
`logics.security.UriAttackDetector` |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
76
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
77
|
알려진 공격·탐색 URI 패턴을 in-memory로 판정한다. |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
78
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
79
|
* `startsWith` 목록: `/wp`, `/wordpress`, `/backup`, `/.env`, `/.git` 등 |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
80
|
* `contains` 목록: `/wp-admin`, `/wp-login.php`, `/phpinfo.php`, `/xmlrpc.php` 등 |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
81
|
* 정규식: `.php` 파일 경로 전반 (`(?i)^/[a-z0-9._=-]{1,64}\.php...`) |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
82
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
83
|
== IpDeny (DB) |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
84
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
85
|
`models.tables.IpDeny` |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
86
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
87
|
* `insert(ip, accessLog, reason)`: actor(`ActorAccessLog`)가 비동기로 호출한다. |
| 2026-09-10T04:20:42 |
Aha00a |
7
|
88
|
* `selectLatest(ip)`: 최신 차단 레코드 조회. `FilterAccessLog`에서 `[0]``[1]` 이 모두 false 이고 `knownClean` 에도 없을 때만 실행된다. |
| 2026-09-08T09:05:15 |
Aha00a |
5
|
89
|
* `deleteExpired`: `Retention` 보다 오래된 레코드를 정리한다. 값과 그 이유는 상수 옆에 있다. |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
90
|
|
| 2026-09-08T09:05:15 |
Aha00a |
5
|
91
|
'''보관 기간이 곧 차단 기간이다.''' `selectLatest` 는 행의 나이를 보지 않으므로, 행이 남아 있는 동안 그 주소는 계속 403 이다. 이 문서는 2026-08-03 에 값이 바뀐 뒤로도 "5년" 이라고 적고 있었다 — 숫자를 산문에 옮겨 적으면 이렇게 갈라진다. 차단을 당장 풀려면 `ipWhitelist` 에 넣는 편이 빠르다(맨 위 표에서 whitelist 가 `IpDeny` 조회 자체를 건너뛴다). |
| 2026-09-08T09:05:15 |
Aha00a |
5
|
92
|
|
| 2026-09-10T19:26:15 |
Aha00a |
8
|
93
|
== 인스턴스가 둘이다 |
| 2026-09-10T19:26:15 |
Aha00a |
8
|
94
|
|
| 2026-09-10T19:26:15 |
Aha00a |
8
|
95
|
운영은 인스턴스 둘이 동시에 요청을 받는다(측정은 [Dev Deploying]). `IpRateLimiter` 의 창·`knownBanned`·`knownClean` 은 인스턴스마다 따로라서, 이 페이지의 흐름은 인스턴스 하나가 보는 것이다. |
| 2026-09-10T19:26:15 |
Aha00a |
8
|
96
|
|
| 2026-09-10T19:26:15 |
Aha00a |
8
|
97
|
* '''임계값은 인스턴스마다 센다.''' 프록시가 요청을 나눠 보내므로 한 인스턴스가 보는 것은 그 주소 요청의 일부다. 두 인스턴스에 고르게 가면, 위 탐지 패턴의 수보다 대략 두 배를 보내야 걸린다. |
| 2026-09-10T19:26:15 |
Aha00a |
8
|
98
|
* '''한쪽이 잡은 주소를 다른 쪽이 아는 길은 `IpDeny` 행뿐이다.''' `[4]` 는 곧바로 행을 쓰지만(actor, 비동기), 다른 인스턴스가 그 주소를 `knownClean` 에 넣어 두었다면 그것이 끝날 때까지(`cleanTtlMs`) DB 를 다시 보지 않는다. `[3]` 은 tarpit 이 끝난 뒤에야 행을 쓰므로, 그 몇 분 동안 다른 인스턴스는 그 주소의 공격 URI 가 아닌 요청을 정상으로 처리한다. |
| 2026-09-10T19:26:15 |
Aha00a |
8
|
99
|
|
| 2026-09-10T19:26:15 |
Aha00a |
8
|
100
|
행이 쓰이고 나면 양쪽이 모두 막으므로, 이 틈은 분 단위다. 차단 기간과 견줄 크기는 아니다. |
| 2026-09-10T19:26:15 |
Aha00a |
8
|
101
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
102
|
== 관련 파일 |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
103
|
|
| 2026-05-27T02:56:56 |
Aha00a |
1
|
104
|
* `app/filters/FilterAccessLog.scala` |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
105
|
* `app/logics/security/IpRateLimiter.scala` |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
106
|
* `app/logics/security/UriAttackDetector.scala` |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
107
|
* `app/models/tables/IpDeny.scala` |
| 2026-05-27T02:56:56 |
Aha00a |
1
|
108
|
* `app/actors/ActorAccessLog.scala` |