apisix 카탈로그 CVE 게이트 완전 해소 (차단 165건 → 0건)
etcd(bitnamilegacy 동결 미러) → etcd.enabled=false + 카탈로그 자체 etcd 차트를 externalEtcd 기본값으로 연결. adc·apisix-ingress-controller·apisix(paasup/apisix) 세 이미지는 SUSE BCI 자체 빌드로 교체 — 전부 벤더 등급만으로는 안 보이던 벤더 하향 등급 CVE(NVD 재평가 시 드러남)가 원인이었다. - images/apisix-ingress-controller: 정적 링크 Go 모듈 취약 버전만 강제 업그레이드 - images/apisix: APISIX-Runtime(WASM·dubbo 등 커스텀 모듈 포함) 전체를 SUSE BCI 위에서 소스로 재현, keycloak-authz 플러그인 오버레이 - images/adc: 업스트림 빌더 스테이지는 그대로 두고 distroless 최종 베이스만 SUSE BCI+nodejs24 로 교체 scripts/build/patch-catalog-tag.py 의 TAG_BLOCK 이 점 구분 중첩 경로를 지원하도록 확장(apisix 서브차트 alias 때문에 필요). 세 이미지 모두 게이트 PASS(실효 CRITICAL/HIGH 0/0)와 배포 검증(테스트 클러스터)을 마쳤다. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
-- keycloak-authz APISIX custom plugin
|
||||
-- Kong keycloak-authz 플러그인을 APISIX로 포팅
|
||||
--
|
||||
-- 동작:
|
||||
-- 1. X-Access-Token 헤더에서 JWT 추출 → preferred_username 파싱
|
||||
-- 2. 외부 API (/gwapi/v1/projectusers/{username}) POST 호출로 권한 확인
|
||||
-- 3. 결과를 TTL 기반으로 in-memory 캐싱 (lrucache)
|
||||
-- 4. 미인가 시 401, 내부 오류 시 500 반환
|
||||
|
||||
local core = require("apisix.core")
|
||||
local http = require("resty.http")
|
||||
local jwt = require("resty.jwt")
|
||||
local lrucache = require("resty.lrucache")
|
||||
local cjson = require("cjson.safe")
|
||||
|
||||
local plugin_name = "keycloak-authz"
|
||||
|
||||
-- 최대 1024개의 사용자 인증 결과를 캐싱
|
||||
local auth_cache, err = lrucache.new(1024)
|
||||
if not auth_cache then
|
||||
error("failed to create lrucache: " .. (err or "unknown"))
|
||||
end
|
||||
|
||||
local schema = {
|
||||
type = "object",
|
||||
properties = {
|
||||
api_url = {
|
||||
type = "string",
|
||||
description = "권한 확인 API의 base URL (예: https://api.example.com)",
|
||||
},
|
||||
basic_auth_token = {
|
||||
type = "string",
|
||||
description = "API 호출 시 사용할 Basic 인증 토큰 (Base64 인코딩된 값)",
|
||||
default = "",
|
||||
},
|
||||
timeout = {
|
||||
type = "integer",
|
||||
description = "API 호출 타임아웃 (밀리초)",
|
||||
default = 5000,
|
||||
minimum = 100,
|
||||
},
|
||||
skip_ssl_verify = {
|
||||
type = "boolean",
|
||||
description = "API 호출 시 SSL 인증서 검증 생략 여부",
|
||||
default = false,
|
||||
},
|
||||
ttl = {
|
||||
type = "integer",
|
||||
description = "인증 결과 캐싱 시간 (초)",
|
||||
default = 300,
|
||||
minimum = 1,
|
||||
},
|
||||
},
|
||||
required = {"api_url"},
|
||||
}
|
||||
|
||||
local _M = {
|
||||
version = 0.1,
|
||||
priority = 950, -- Kong의 PRIORITY = 950과 동일
|
||||
name = plugin_name,
|
||||
schema = schema,
|
||||
}
|
||||
|
||||
function _M.check_schema(conf)
|
||||
return core.schema.check(schema, conf)
|
||||
end
|
||||
|
||||
-- 외부 API 호출로 사용자 권한 확인
|
||||
local function fetch_auth_status(api_url, preferred_username, basic_auth_token, timeout, skip_ssl_verify)
|
||||
local httpc = http.new()
|
||||
|
||||
local scheme = ngx.var.scheme
|
||||
local host = ngx.var.host
|
||||
local url = scheme .. "://" .. host
|
||||
|
||||
local body, encode_err = cjson.encode({ url = url })
|
||||
if encode_err then
|
||||
return nil, "failed to encode request body: " .. encode_err
|
||||
end
|
||||
|
||||
local headers = { ["Content-Type"] = "application/json" }
|
||||
if basic_auth_token and basic_auth_token ~= "" then
|
||||
headers["Authorization"] = "Basic " .. basic_auth_token
|
||||
end
|
||||
|
||||
local full_url = api_url .. "/gwapi/v1/projectusers/" .. preferred_username
|
||||
|
||||
local res, req_err = httpc:request_uri(full_url, {
|
||||
method = "POST",
|
||||
body = body,
|
||||
headers = headers,
|
||||
timeout = timeout,
|
||||
ssl_verify = not skip_ssl_verify,
|
||||
keepalive_timeout = 60000,
|
||||
keepalive_pool = 10,
|
||||
})
|
||||
|
||||
httpc:close()
|
||||
|
||||
if not res then
|
||||
return nil, "API request failed: " .. (req_err or "unknown error")
|
||||
end
|
||||
|
||||
core.log.debug("auth API response status=", res.status, " user=", preferred_username)
|
||||
return res.status == 200, nil
|
||||
end
|
||||
|
||||
function _M.access(conf, ctx)
|
||||
-- X-Access-Token 헤더 추출
|
||||
local access_token = core.request.header(ctx, "X-Access-Token")
|
||||
if not access_token then
|
||||
return core.response.exit(401, { message = "Missing X-Access-Token header" })
|
||||
end
|
||||
|
||||
-- JWT 디코딩 (서명 검증 없이 페이로드만 파싱)
|
||||
local jwt_obj = jwt:load_jwt(access_token)
|
||||
if not jwt_obj or not jwt_obj.payload then
|
||||
core.log.warn("failed to decode JWT token")
|
||||
return core.response.exit(401, { message = "Failed to decode JWT token" })
|
||||
end
|
||||
|
||||
local preferred_username = jwt_obj.payload["preferred_username"]
|
||||
if not preferred_username then
|
||||
return core.response.exit(401, { message = "Missing preferred_username in JWT token" })
|
||||
end
|
||||
|
||||
local cache_key = "auth_status:" .. preferred_username
|
||||
|
||||
-- 캐시 조회
|
||||
local cached_result = auth_cache:get(cache_key)
|
||||
if cached_result ~= nil then
|
||||
core.log.debug("cache hit for user=", preferred_username, " result=", tostring(cached_result))
|
||||
if not cached_result then
|
||||
return core.response.exit(401, { message = "User is not authorized" })
|
||||
end
|
||||
ctx.authenticated_user = preferred_username
|
||||
return
|
||||
end
|
||||
|
||||
-- 캐시 미스: 외부 API 호출
|
||||
core.log.debug("cache miss, fetching auth status for user=", preferred_username)
|
||||
|
||||
local is_authorized, fetch_err = fetch_auth_status(
|
||||
conf.api_url,
|
||||
preferred_username,
|
||||
conf.basic_auth_token or "",
|
||||
conf.timeout or 5000,
|
||||
conf.skip_ssl_verify or false
|
||||
)
|
||||
|
||||
if fetch_err then
|
||||
core.log.error("auth check failed user=", preferred_username, " err=", fetch_err)
|
||||
return core.response.exit(500, { message = "Internal server error" })
|
||||
end
|
||||
|
||||
-- 결과 캐싱 (TTL 적용)
|
||||
auth_cache:set(cache_key, is_authorized, conf.ttl or 300)
|
||||
core.log.debug("cached auth result user=", preferred_username, " authorized=", tostring(is_authorized))
|
||||
|
||||
if not is_authorized then
|
||||
core.log.warn("authorization denied for user=", preferred_username)
|
||||
return core.response.exit(401, { message = "User is not authorized" })
|
||||
end
|
||||
|
||||
ctx.authenticated_user = preferred_username
|
||||
end
|
||||
|
||||
return _M
|
||||
Reference in New Issue
Block a user