HH-743: harden browser image and lifecycle (#5)

This commit is contained in:
2026-08-27 18:45:48 +08:00
parent 4b054d6f04
commit f24ed999cb
8 changed files with 229 additions and 35 deletions
+3 -3
View File
@@ -1,18 +1,18 @@
FROM node:22-alpine AS web
FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS web
WORKDIR /src/web
COPY web/package*.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM golang:1.26-alpine AS go
FROM golang:1.26-alpine@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS go
WORKDIR /src
COPY go.mod ./
COPY cmd/ ./cmd/
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/control-plane ./cmd/control-plane \
&& CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/docker-gateway ./cmd/docker-gateway
FROM alpine:3.22
FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
RUN addgroup -g 65532 app && adduser -D -u 65532 -G app app
WORKDIR /app
COPY --from=go /out/control-plane /out/docker-gateway /app/
+2 -1
View File
@@ -14,7 +14,7 @@
需要 Docker Compose。控制面仅绑定本机;浏览器网络在阶段 A 默认禁止外网访问:
```bash
docker pull git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215
docker pull git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7
DOCKER_GID=$(stat -c %g /var/run/docker.sock) docker compose up --build
```
@@ -28,6 +28,7 @@ go test ./...
npm --prefix web ci
npm --prefix web run build
docker compose config --quiet
CREATORHUB_DOCKER_INTEGRATION=1 go test -run TestDockerIntegrationBlocksCrossContainerTraffic ./cmd/docker-gateway
```
## 合规边界
+12 -10
View File
@@ -16,7 +16,17 @@ func main() {
if err != nil {
log.Fatal(err)
}
server := &http.Server{
Addr: env("LISTEN_ADDR", ":8080"),
Handler: newHandler(gateway, env("WEB_DIR", "web/dist")),
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("CreatorHub control plane listening on %s", server.Addr)
log.Fatal(server.ListenAndServe())
}
func newHandler(gateway *url.URL, webDirectory string) http.Handler {
proxy := httputil.NewSingleHostReverseProxy(gateway)
originalDirector := proxy.Director
proxy.Director = func(request *http.Request) {
@@ -31,16 +41,8 @@ func main() {
mux.HandleFunc("GET /healthz", func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusNoContent)
})
mux.Handle("/", spaHandler(env("WEB_DIR", "web/dist")))
server := &http.Server{
Addr: env("LISTEN_ADDR", ":8080"),
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("CreatorHub control plane listening on %s", server.Addr)
log.Fatal(server.ListenAndServe())
mux.Handle("/", spaHandler(webDirectory))
return mux
}
func spaHandler(directory string) http.Handler {
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
func TestAPIProxyPreservesMethodPathAndStatus(t *testing.T) {
requests := make(chan *http.Request, 1)
gateway := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
requests <- request
response.WriteHeader(http.StatusServiceUnavailable)
}))
defer gateway.Close()
upstream, err := url.Parse(gateway.URL)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/browsers/account-a/start", nil)
response := httptest.NewRecorder()
newHandler(upstream, t.TempDir()).ServeHTTP(response, request)
proxied := <-requests
if proxied.Method != http.MethodPost || proxied.URL.Path != "/v1/browsers/account-a/start" {
t.Fatalf("unexpected proxy request %s %s", proxied.Method, proxied.URL.Path)
}
if response.Code != http.StatusServiceUnavailable {
t.Fatalf("expected upstream status 503, got %d", response.Code)
}
}
+26 -16
View File
@@ -19,6 +19,9 @@ import (
)
const (
browserImage = "git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7"
browserUser = "1000:1000"
browserEntrypoint = "/usr/local/bin/docker-entrypoint.sh"
managedLabel = "io.creatorhub.managed"
idLabel = "io.creatorhub.runtime-id"
networkRoleLabel = "io.creatorhub.network-role"
@@ -30,6 +33,8 @@ const (
var runtimeIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
var networkNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
var errInvalidRuntimeID = errors.New("invalid runtime id")
var errUnmanagedContainer = errors.New("refusing to operate on a container not owned by CreatorHub")
type config struct {
image string
@@ -61,11 +66,6 @@ type browser struct {
}
func main() {
image := os.Getenv("BROWSER_IMAGE")
if image == "" || strings.HasSuffix(image, ":latest") || !strings.Contains(image, ":") {
log.Fatal("BROWSER_IMAGE must be a pinned image tag")
}
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", env("DOCKER_SOCKET", "/var/run/docker.sock"))
@@ -79,7 +79,7 @@ func main() {
if err := docker.ensureInternalNetwork(network); err != nil {
log.Fatal(err)
}
handler := newGateway(docker, config{image: image, network: network})
handler := newGateway(docker, config{image: browserImage, network: network})
server := &http.Server{
Addr: env("LISTEN_ADDR", ":8081"),
@@ -161,9 +161,11 @@ func (api gateway) create(response http.ResponseWriter, request *http.Request) {
pidsLimit := int64(512)
payload := map[string]any{
"Image": api.config.image,
"Cmd": []string{"--fingerprint=" + strconv.FormatInt(input.Seed, 10), "about:blank"},
"Env": []string{"REMOTE_DEBUGGING_PORT=9222"},
"Image": api.config.image,
"User": browserUser,
"Entrypoint": []string{browserEntrypoint},
"Cmd": []string{"--fingerprint=" + strconv.FormatInt(input.Seed, 10), "about:blank"},
"Env": []string{"REMOTE_DEBUGGING_PORT=9222"},
"Labels": map[string]string{
managedLabel: "true",
idLabel: input.Name,
@@ -208,7 +210,12 @@ func (api gateway) create(response http.ResponseWriter, request *http.Request) {
return
}
if err := api.docker.expect(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/start", nil, http.StatusNoContent, http.StatusNotModified); err != nil {
writeError(response, http.StatusBadGateway, fmt.Errorf("container created but did not start: %w", err))
cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=1&v=0", nil, http.StatusNoContent)
if cleanupErr != nil {
writeError(response, http.StatusBadGateway, fmt.Errorf("container did not start and cleanup failed: %w; cleanup: %v", err, cleanupErr))
return
}
writeError(response, http.StatusBadGateway, fmt.Errorf("container did not start and was removed while preserving its Profile volume: %w", err))
return
}
writeJSON(response, http.StatusCreated, map[string]string{"id": created.ID, "name": input.Name})
@@ -254,7 +261,7 @@ func (api gateway) remove(response http.ResponseWriter, request *http.Request) {
func (api gateway) requireManaged(id string) error {
if !runtimeIDPattern.MatchString(id) {
return errors.New("invalid runtime id")
return errInvalidRuntimeID
}
result, err := api.docker.request(http.MethodGet, "/containers/"+url.PathEscape(namePrefix+id)+"/json", nil)
if err != nil {
@@ -276,7 +283,7 @@ func (api gateway) requireManaged(id string) error {
return fmt.Errorf("decode Docker inspect: %w", err)
}
if inspected.Config.Labels[managedLabel] != "true" || inspected.Config.Labels[idLabel] != id {
return errors.New("refusing to operate on a container not owned by CreatorHub")
return errUnmanagedContainer
}
return nil
}
@@ -364,13 +371,16 @@ func (docker dockerClient) ensureInternalNetwork(name string) error {
}
func statusFor(err error) int {
if errors.Is(err, os.ErrNotExist) {
switch {
case errors.Is(err, errInvalidRuntimeID):
return http.StatusBadRequest
case errors.Is(err, os.ErrNotExist):
return http.StatusNotFound
}
if strings.Contains(err.Error(), "refusing") {
case errors.Is(err, errUnmanagedContainer):
return http.StatusForbidden
default:
return http.StatusBadGateway
}
return http.StatusBadRequest
}
func forwardDockerError(response http.ResponseWriter, result *http.Response) {
+147
View File
@@ -1,11 +1,17 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"strings"
"testing"
"time"
)
func TestGatewayCreatesConstrainedBrowser(t *testing.T) {
@@ -43,6 +49,9 @@ func TestGatewayCreatesConstrainedBrowser(t *testing.T) {
if created["Image"] != "registry.example/browser:1.2.3" {
t.Fatalf("gateway accepted an unexpected image: %#v", created["Image"])
}
if created["User"] != browserUser || created["Entrypoint"].([]any)[0] != browserEntrypoint {
t.Fatalf("runtime identity is not fixed: user=%#v entrypoint=%#v", created["User"], created["Entrypoint"])
}
host := created["HostConfig"].(map[string]any)
if host["NetworkMode"] != "creatorhub_browser" || host["ReadonlyRootfs"] != true {
t.Fatalf("missing container isolation: %#v", host)
@@ -57,6 +66,101 @@ func TestGatewayCreatesConstrainedBrowser(t *testing.T) {
}
}
func TestGatewayRemovesFailedContainerAndPreservesProfile(t *testing.T) {
removed := false
docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
switch {
case request.Method == http.MethodPost && request.URL.Path == "/containers/create":
response.WriteHeader(http.StatusCreated)
_, _ = response.Write([]byte(`{"Id":"failed-id"}`))
case request.Method == http.MethodPost && request.URL.Path == "/containers/failed-id/start":
http.Error(response, "start failed", http.StatusInternalServerError)
case request.Method == http.MethodDelete && request.URL.Path == "/containers/failed-id":
removed = request.URL.Query().Get("force") == "1" && request.URL.Query().Get("v") == "0"
response.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
}
}))
defer docker.Close()
handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, config{image: browserImage, network: "creatorhub_browser"})
request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(`{"name":"account-a","seed":1000}`))
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadGateway || !removed {
t.Fatalf("expected failed container cleanup with preserved volume, status=%d removed=%v body=%s", response.Code, removed, response.Body.String())
}
}
func TestGatewayListsBrowsers(t *testing.T) {
docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet || request.URL.Path != "/containers/json" {
t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
}
_, _ = response.Write([]byte(`[{"Id":"container-id","State":"running","Status":"Up","Labels":{"io.creatorhub.runtime-id":"account-a"}}]`))
}))
defer docker.Close()
handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, config{})
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/browsers", nil))
var browsers []browser
if response.Code != http.StatusOK || json.NewDecoder(response.Body).Decode(&browsers) != nil || len(browsers) != 1 || browsers[0].Name != "account-a" {
t.Fatalf("unexpected list response status=%d body=%s", response.Code, response.Body.String())
}
}
func TestGatewayLifecycle(t *testing.T) {
tests := []struct {
method string
path string
dockerPath string
}{
{http.MethodPost, "/v1/browsers/account-a/start", "/containers/creatorhub-browser-account-a/start"},
{http.MethodPost, "/v1/browsers/account-a/stop", "/containers/creatorhub-browser-account-a/stop"},
{http.MethodDelete, "/v1/browsers/account-a", "/containers/creatorhub-browser-account-a"},
}
for _, test := range tests {
t.Run(test.method+" "+test.path, func(t *testing.T) {
docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodGet {
_, _ = response.Write([]byte(`{"Config":{"Labels":{"io.creatorhub.managed":"true","io.creatorhub.runtime-id":"account-a"}}}`))
return
}
if request.URL.Path != test.dockerPath {
t.Fatalf("unexpected Docker path %s", request.URL.String())
}
response.WriteHeader(http.StatusNoContent)
}))
defer docker.Close()
handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, config{})
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil))
if response.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", response.Code, response.Body.String())
}
})
}
}
func TestGatewayMapsDockerServiceFailureToBadGateway(t *testing.T) {
docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
http.Error(response, "daemon unavailable", http.StatusInternalServerError)
}))
defer docker.Close()
handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, config{})
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodDelete, "/v1/browsers/account-a", nil))
if response.Code != http.StatusBadGateway {
t.Fatalf("expected 502 for Docker failure, got %d: %s", response.Code, response.Body.String())
}
}
func TestGatewayRefusesUnmanagedContainer(t *testing.T) {
deleted := false
docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
@@ -183,3 +287,46 @@ func TestEnsureInternalNetworkRejectsControlNetwork(t *testing.T) {
t.Fatalf("expected control network to be rejected before Docker request, requested=%v err=%v", requested, err)
}
}
func TestDockerIntegrationBlocksCrossContainerTraffic(t *testing.T) {
if os.Getenv("CREATORHUB_DOCKER_INTEGRATION") != "1" {
t.Skip("set CREATORHUB_DOCKER_INTEGRATION=1 to run")
}
image := "alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce"
networkName := fmt.Sprintf("creatorhub_test_%d", time.Now().UnixNano())
serverName := networkName + "_server"
runDocker := func(args ...string) ([]byte, error) {
return exec.Command("docker", args...).CombinedOutput()
}
if output, err := runDocker("pull", image); err != nil {
t.Fatalf("pull integration image: %v: %s", err, output)
}
t.Cleanup(func() {
if output, err := runDocker("network", "rm", networkName); err != nil {
t.Logf("remove test network: %v: %s", err, output)
}
})
t.Cleanup(func() {
if output, err := runDocker("rm", "-f", serverName); err != nil {
t.Logf("remove test container: %v: %s", err, output)
}
})
transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock")
}}
defer transport.CloseIdleConnections()
client := dockerClient{baseURL: "http://docker/v1.43", client: &http.Client{Transport: transport}}
if err := client.ensureInternalNetwork(networkName); err != nil {
t.Fatal(err)
}
if output, err := runDocker("run", "-d", "--name", serverName, "--network", networkName, "--network-alias", "peer", image, "nc", "-lk", "-p", "8080", "-e", "/bin/echo"); err != nil {
t.Fatalf("start peer server: %v: %s", err, output)
}
if output, err := runDocker("exec", serverName, "nc", "-z", "-w", "2", "127.0.0.1", "8080"); err != nil {
t.Fatalf("peer server is not ready: %v: %s", err, output)
}
if output, err := runDocker("run", "--rm", "--network", networkName, image, "nc", "-z", "-w", "2", "peer", "8080"); err == nil {
t.Fatalf("cross-container request unexpectedly succeeded: %s", output)
}
}
-1
View File
@@ -18,7 +18,6 @@ services:
build: .
command: ["/app/docker-gateway"]
environment:
BROWSER_IMAGE: ${BROWSER_IMAGE:-git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215}
BROWSER_NETWORK: creatorhub_browser
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
+6 -4
View File
@@ -27,17 +27,19 @@ React ── /api/browsers ──> control-plane ── /v1/browsers ──> doc
- 启停和删除前必须同时匹配固定名称前缀及 `io.creatorhub.managed`、`io.creatorhub.runtime-id` 标签;
- 动态容器使用只读根文件系统、非 root 镜像、全部 capability drop、`no-new-privileges`、CPU/内存/PID 限制,且无宿主机端口和目录挂载;
- 控制面只绑定 `127.0.0.1`,控制网络为固定名称的 Compose internal 网络;浏览器 bridge 关闭 ICC,阻止运行时直接互访,并按 ownership、role、driver、Internal 和完整 options 失败关闭校验,且拒绝复用 control 网络;
- `BROWSER_IMAGE` 必须是固定 tag,拒绝 `latest`。
- 浏览器镜像固定为审核过的 immutable digest;运行时创建请求同时固定非 root `1000:1000` 与镜像入口,构建阶段和最终基础镜像也均锁定 digest。
网关自身一旦被攻破,socket 仍允许接管宿主机;应用内校验不能消除这个平台级风险。进入共享或生产环境前,应把浏览器调度迁到独立 Docker daemon/VM,或使用宿主机 Docker authorization plugin 做第二层强制授权,不应把 socket 暴露给公网服务。
网关自身一旦被攻破,socket 仍允许接管宿主机;应用内校验不能消除这个平台级风险。当前控制面没有认证或 CSRF 防护,只支持可信单用户从 localhost 访问。进入共享或生产环境前,必须先补认证与 CSRF 边界,并把浏览器调度迁到独立 Docker daemon/VM,或使用宿主机 Docker authorization plugin 做第二层强制授权,不应把 socket 暴露给公网服务。
## 运行
Docker socket 的 GID 因宿主机而异:
```bash
docker pull git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215
docker pull git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7
DOCKER_GID=$(stat -c %g /var/run/docker.sock) docker compose up --build
```
打开 <http://127.0.0.1:8080>。默认浏览器镜像固定为 `git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215`,可通过 `BROWSER_IMAGE` 改为其他已审核的固定 tag。阶段 A 网络默认失败关闭且不能访问外网;后续授权沙箱必须先设计受管出口,再调整 `browser` 网络。
打开 <http://127.0.0.1:8080>。浏览器镜像固定为 `git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7`。阶段 A 网络默认失败关闭且不能访问外网;后续授权沙箱必须先设计受管出口,再调整 `browser` 网络。
创建成功但启动失败时,网关会立即删除失败容器并保留命名 Profile 卷,允许同名请求安全重试。跨容器隔离回归可用 `CREATORHUB_DOCKER_INTEGRATION=1 go test -run TestDockerIntegrationBlocksCrossContainerTraffic ./cmd/docker-gateway` 运行。