HH-868: add fail-closed Douyin read-only connector core (#31)
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/douyin"
|
||||
"git.ipao.vip/rogee/creator-hub/internal/hub"
|
||||
)
|
||||
|
||||
type douyinGatewayBrowser struct {
|
||||
gateway hub.Gateway
|
||||
environment hub.EnvironmentContext
|
||||
}
|
||||
|
||||
type douyinGatewayRequest struct {
|
||||
BindingVersion int64 `json:"binding_version"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
NetworkID string `json:"network_id"`
|
||||
NetworkExitID string `json:"network_exit_id"`
|
||||
Cookies []douyin.Cookie `json:"cookies,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
func (browser douyinGatewayBrowser) SetCookies(ctx context.Context, cookies []douyin.Cookie) error {
|
||||
request, err := browser.request()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Cookies = cookies
|
||||
status, _, err := gatewayCall(ctx, browser.gateway, http.MethodPost,
|
||||
"/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/cookies", request, 30*time.Second)
|
||||
if err != nil || status != http.StatusNoContent {
|
||||
return errors.New("restricted browser operation failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (browser douyinGatewayBrowser) Get(ctx context.Context, target string) (douyin.Response, error) {
|
||||
request, err := browser.request()
|
||||
if err != nil {
|
||||
return douyin.Response{}, err
|
||||
}
|
||||
request.URL = target
|
||||
status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost,
|
||||
"/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/get", request, 30*time.Second)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return douyin.Response{}, errors.New("restricted browser operation failed")
|
||||
}
|
||||
var response struct {
|
||||
Status int `json:"status"`
|
||||
Body string `json:"body"`
|
||||
Challenge douyin.Challenge `json:"challenge"`
|
||||
}
|
||||
if json.Unmarshal(body, &response) != nil || response.Status < 100 || response.Status > 599 ||
|
||||
(response.Challenge != douyin.ChallengeNone && response.Challenge != douyin.ChallengeCaptcha &&
|
||||
response.Challenge != douyin.ChallengeDevice) {
|
||||
return douyin.Response{}, errors.New("restricted browser returned an invalid response")
|
||||
}
|
||||
return douyin.Response{Status: response.Status, Body: []byte(response.Body), Challenge: response.Challenge}, nil
|
||||
}
|
||||
|
||||
func (browser douyinGatewayBrowser) request() (douyinGatewayRequest, error) {
|
||||
environment := browser.environment
|
||||
if browser.gateway.Endpoint == "" || browser.gateway.Token == "" || !accountRunnable(environment) ||
|
||||
!gatewayGenerationIDPattern.MatchString(environment.AccountID) || !gatewayGenerationIDPattern.MatchString(environment.Alias) ||
|
||||
!gatewayGenerationIDPattern.MatchString(environment.BindingID) ||
|
||||
!gatewayGenerationIDPattern.MatchString(environment.Exit.ID) || environment.Exit.HealthStatus != "healthy" || environment.RuntimeCleanupPending ||
|
||||
!gatewayGenerationIDPattern.MatchString(environment.RuntimeInstanceID) || !gatewayGenerationIDPattern.MatchString(environment.RuntimeID) ||
|
||||
!gatewayGenerationIDPattern.MatchString(environment.RuntimeNetworkID) ||
|
||||
environment.BindingVersion < 1 {
|
||||
return douyinGatewayRequest{}, errors.New("restricted browser is not ready")
|
||||
}
|
||||
return douyinGatewayRequest{BindingVersion: environment.BindingVersion, RuntimeID: environment.RuntimeID,
|
||||
NetworkID: environment.RuntimeNetworkID, NetworkExitID: environment.Exit.ID}, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/douyin"
|
||||
"git.ipao.vip/rogee/creator-hub/internal/hub"
|
||||
)
|
||||
|
||||
const testDouyinIdentityURL = "https://www.douyin.com/aweme/v1/web/user/profile/self/"
|
||||
|
||||
func TestDouyinGatewayBrowserFencesAccountGeneration(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
requests++
|
||||
if request.Header.Get("Authorization") != "Bearer gateway-token-1" {
|
||||
t.Fatalf("missing gateway authorization")
|
||||
}
|
||||
var body map[string]any
|
||||
if json.NewDecoder(request.Body).Decode(&body) != nil || body["binding_version"] != float64(2) ||
|
||||
body["runtime_id"] != "runtime-a" || body["network_id"] != "network-a" || body["network_exit_id"] != "exit-a" {
|
||||
t.Fatalf("generation fence missing: %#v", body)
|
||||
}
|
||||
switch request.URL.Path {
|
||||
case "/v1/browsers/account-a/douyin/cookies":
|
||||
cookies, ok := body["cookies"].([]any)
|
||||
if !ok || len(cookies) != 1 {
|
||||
t.Fatalf("cookies missing: %#v", body)
|
||||
}
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
case "/v1/browsers/account-a/douyin/get":
|
||||
if body["url"] != testDouyinIdentityURL {
|
||||
t.Fatalf("unexpected URL: %#v", body)
|
||||
}
|
||||
_ = json.NewEncoder(response).Encode(map[string]any{"status": 412, "body": `{"captcha":true}`, "challenge": "captcha"})
|
||||
default:
|
||||
response.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
browser := douyinGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, environment: readyDouyinEnvironment()}
|
||||
if err := browser.SetCookies(context.Background(), []douyin.Cookie{{Name: "sessionid", Value: "private-session", Domain: ".douyin.com", Path: "/"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := browser.Get(context.Background(), testDouyinIdentityURL)
|
||||
if err != nil || result.Status != 412 || result.Challenge != douyin.ChallengeCaptcha || requests != 2 {
|
||||
t.Fatalf("unexpected result: %#v requests=%d err=%v", result, requests, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDouyinGatewayBrowserFailsClosedWithoutReadyBinding(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ }))
|
||||
defer server.Close()
|
||||
environment := readyDouyinEnvironment()
|
||||
environment.Exit.HealthStatus = "unhealthy"
|
||||
browser := douyinGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, environment: environment}
|
||||
if _, err := browser.Get(context.Background(), testDouyinIdentityURL); err == nil || requests != 0 {
|
||||
t.Fatalf("unready binding reached gateway: requests=%d err=%v", requests, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDouyinGatewayBrowserDoesNotEchoCredentialOnFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
response.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = response.Write([]byte(`{"error":"private-session"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
browser := douyinGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, environment: readyDouyinEnvironment()}
|
||||
err := browser.SetCookies(context.Background(), []douyin.Cookie{{Name: "sessionid", Value: "private-session", Domain: ".douyin.com", Path: "/"}})
|
||||
if err == nil || strings.Contains(err.Error(), "private-session") {
|
||||
t.Fatalf("gateway failure leaked credential: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readyDouyinEnvironment() hub.EnvironmentContext {
|
||||
return hub.EnvironmentContext{Env: hub.Env{Alias: "account-a", Gateway: "gateway-a"}, AccountID: "account-a",
|
||||
AccountStatus: "active", AuthorizationStatus: "authorized", BindingID: "binding-a", BindingVersion: 2,
|
||||
Exit: hub.NetworkExit{ID: "exit-a", HealthStatus: "healthy"}, RuntimeInstanceID: "instance-a",
|
||||
RuntimeID: "runtime-a", RuntimeNetworkID: "network-a"}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/douyin"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
douyinOrigin = "https://www.douyin.com"
|
||||
douyinOriginURL = "https://www.douyin.com/"
|
||||
douyinIdentityPath = "/aweme/v1/web/user/profile/self/"
|
||||
douyinWorksPath = "/aweme/v1/web/aweme/post/"
|
||||
douyinResponseLimit = 1 << 20
|
||||
browserControlTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
var douyinAccountKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$`)
|
||||
|
||||
type restrictedBrowserResponse struct {
|
||||
Status int
|
||||
Body string
|
||||
Challenge douyin.Challenge
|
||||
}
|
||||
|
||||
type restrictedBrowser interface {
|
||||
SetCookies(context.Context, string, []douyin.Cookie) error
|
||||
Get(context.Context, string, string) (restrictedBrowserResponse, error)
|
||||
}
|
||||
|
||||
type douyinGenerationRequest struct {
|
||||
BindingVersion int64 `json:"binding_version"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
NetworkID string `json:"network_id"`
|
||||
NetworkExitID string `json:"network_exit_id"`
|
||||
}
|
||||
|
||||
type douyinCookieRequest struct {
|
||||
douyinGenerationRequest
|
||||
Cookies []douyin.Cookie `json:"cookies"`
|
||||
}
|
||||
|
||||
type douyinGetRequest struct {
|
||||
douyinGenerationRequest
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (api gateway) setDouyinCookies(c fiber.Ctx) error {
|
||||
var input douyinCookieRequest
|
||||
if !runtimeIDPattern.MatchString(c.Params("id")) || decodeRestrictedBrowserRequest(c.Body(), &input) != nil || !validDouyinGeneration(input.douyinGenerationRequest) ||
|
||||
!validDouyinCookies(input.Cookies) {
|
||||
return writeError(c, http.StatusBadRequest, errors.New("invalid restricted browser request"))
|
||||
}
|
||||
_, release, err := api.locks.acquire(c.Params("id"))
|
||||
if err != nil {
|
||||
return writeError(c, statusFor(err), err)
|
||||
}
|
||||
defer release()
|
||||
if err := api.requireDouyinGeneration(c.Params("id"), input.douyinGenerationRequest); err != nil {
|
||||
return writeError(c, statusFor(err), err)
|
||||
}
|
||||
if api.browser == nil || api.browser.SetCookies(c.Context(), c.Params("id"), input.Cookies) != nil {
|
||||
return writeError(c, http.StatusBadGateway, errors.New("restricted browser operation failed"))
|
||||
}
|
||||
if err := api.requireDouyinGeneration(c.Params("id"), input.douyinGenerationRequest); err != nil {
|
||||
return writeError(c, statusFor(err), err)
|
||||
}
|
||||
return c.SendStatus(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api gateway) getDouyin(c fiber.Ctx) error {
|
||||
var input douyinGetRequest
|
||||
if !runtimeIDPattern.MatchString(c.Params("id")) || decodeRestrictedBrowserRequest(c.Body(), &input) != nil || !validDouyinGeneration(input.douyinGenerationRequest) ||
|
||||
!validDouyinURL(input.URL) {
|
||||
return writeError(c, http.StatusBadRequest, errors.New("invalid restricted browser request"))
|
||||
}
|
||||
_, release, err := api.locks.acquire(c.Params("id"))
|
||||
if err != nil {
|
||||
return writeError(c, statusFor(err), err)
|
||||
}
|
||||
defer release()
|
||||
if err := api.requireDouyinGeneration(c.Params("id"), input.douyinGenerationRequest); err != nil {
|
||||
return writeError(c, statusFor(err), err)
|
||||
}
|
||||
if api.browser == nil {
|
||||
return writeError(c, http.StatusBadGateway, errors.New("restricted browser operation failed"))
|
||||
}
|
||||
response, err := api.browser.Get(c.Context(), c.Params("id"), input.URL)
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusBadGateway, errors.New("restricted browser operation failed"))
|
||||
}
|
||||
if err := api.requireDouyinGeneration(c.Params("id"), input.douyinGenerationRequest); err != nil {
|
||||
return writeError(c, statusFor(err), err)
|
||||
}
|
||||
return writeJSON(c, http.StatusOK, map[string]any{
|
||||
"status": response.Status, "body": response.Body, "challenge": response.Challenge,
|
||||
})
|
||||
}
|
||||
|
||||
func decodeRestrictedBrowserRequest(body []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
var trailing json.RawMessage
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return errors.New("request must contain one JSON value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validDouyinGeneration(input douyinGenerationRequest) bool {
|
||||
return input.BindingVersion > 0 && exitIDPattern.MatchString(input.RuntimeID) &&
|
||||
exitIDPattern.MatchString(input.NetworkID) && exitIDPattern.MatchString(input.NetworkExitID)
|
||||
}
|
||||
|
||||
func (api gateway) requireDouyinGeneration(alias string, input douyinGenerationRequest) error {
|
||||
runtimeID, labels, networks, err := api.managedContainerState(alias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version, _ := strconv.ParseInt(labels[bindingVersionLabel], 10, 64)
|
||||
if runtimeID != input.RuntimeID || version != input.BindingVersion || labels[networkIDLabel] != input.NetworkID ||
|
||||
labels[networkExitLabel] != input.NetworkExitID {
|
||||
return errGenerationConflict
|
||||
}
|
||||
generation, _, exists, err := api.docker.inspectTenantNetwork(api.network, alias, input.BindingVersion,
|
||||
input.RuntimeID, api.self, input.NetworkID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists || !generation.RuntimeAttached || generation.SelfMember == "" || len(generation.GatewayMembers) == 0 ||
|
||||
len(networks) != 1 || networks[generation.Name] != generation.ID {
|
||||
return errGenerationConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validDouyinCookies(cookies []douyin.Cookie) bool {
|
||||
if len(cookies) == 0 || len(cookies) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, cookie := range cookies {
|
||||
domain := strings.ToLower(strings.TrimSpace(cookie.Domain))
|
||||
if cookie.Name == "" || len(cookie.Name) > 256 || len(cookie.Value) > 4096 || len(cookie.Domain) > 256 || len(cookie.Path) > 256 ||
|
||||
domain != cookie.Domain || cookie.Expires < 0 || strings.ContainsAny(cookie.Name, ";\r\n\x00") || strings.ContainsAny(cookie.Value, ";\r\n\x00") ||
|
||||
(domain != "douyin.com" && !strings.HasSuffix(domain, ".douyin.com")) || !strings.HasPrefix(cookie.Path, "/") ||
|
||||
strings.ContainsAny(cookie.Path, ";\r\n\x00") || (cookie.SameSite != "" && cookie.SameSite != "Lax" &&
|
||||
cookie.SameSite != "Strict" && cookie.SameSite != "None") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validDouyinURL(raw string) bool {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host != "www.douyin.com" || parsed.User != nil || parsed.Fragment != "" {
|
||||
return false
|
||||
}
|
||||
query := parsed.Query()
|
||||
switch parsed.Path {
|
||||
case douyinIdentityPath:
|
||||
return parsed.RawQuery == ""
|
||||
case douyinWorksPath:
|
||||
return len(query) == 3 && len(query["sec_user_id"]) == 1 && douyinAccountKeyPattern.MatchString(query.Get("sec_user_id")) &&
|
||||
len(query["count"]) == 1 && query.Get("count") == "20" && len(query["max_cursor"]) == 1 && query.Get("max_cursor") == "0"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type cdpBrowser struct {
|
||||
endpoint func(string) string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (browser cdpBrowser) SetCookies(ctx context.Context, alias string, cookies []douyin.Cookie) error {
|
||||
connection, err := browser.connect(ctx, alias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer connection.Close()
|
||||
commandID := 0
|
||||
cdpCookies := make([]map[string]any, 0, len(cookies))
|
||||
for _, cookie := range cookies {
|
||||
value := map[string]any{
|
||||
"name": cookie.Name, "value": cookie.Value, "url": douyinOriginURL, "path": cookie.Path,
|
||||
"secure": cookie.Secure, "httpOnly": cookie.HTTPOnly,
|
||||
}
|
||||
if cookie.SameSite != "" {
|
||||
value["sameSite"] = cookie.SameSite
|
||||
}
|
||||
if cookie.Expires != 0 {
|
||||
value["expires"] = cookie.Expires
|
||||
}
|
||||
cdpCookies = append(cdpCookies, value)
|
||||
}
|
||||
if err := cdpCommand(connection, &commandID, "Network.enable", map[string]any{}, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cdpCommand(connection, &commandID, "Network.clearBrowserCookies", map[string]any{}, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cdpCommand(connection, &commandID, "Page.enable", map[string]any{}, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cdpCommand(connection, &commandID, "Page.setLifecycleEventsEnabled", map[string]bool{"enabled": true}, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
var events []cdpMessage
|
||||
var navigation struct {
|
||||
FrameID string `json:"frameId"`
|
||||
LoaderID string `json:"loaderId"`
|
||||
ErrorText string `json:"errorText"`
|
||||
}
|
||||
if err := cdpCommand(connection, &commandID, "Page.navigate", map[string]string{"url": douyinOriginURL}, &navigation, &events); err != nil ||
|
||||
navigation.ErrorText != "" || navigation.FrameID == "" || navigation.LoaderID == "" {
|
||||
return errors.New("restricted browser navigation failed")
|
||||
}
|
||||
if err := waitForDouyinPage(ctx, connection, &commandID, navigation.FrameID, navigation.LoaderID, events); err != nil {
|
||||
return err
|
||||
}
|
||||
return cdpCommand(connection, &commandID, "Network.setCookies", map[string]any{"cookies": cdpCookies}, nil, nil)
|
||||
}
|
||||
|
||||
func (browser cdpBrowser) Get(ctx context.Context, alias, target string) (restrictedBrowserResponse, error) {
|
||||
connection, err := browser.connect(ctx, alias)
|
||||
if err != nil {
|
||||
return restrictedBrowserResponse{}, err
|
||||
}
|
||||
defer connection.Close()
|
||||
commandID := 0
|
||||
var currentOrigin struct {
|
||||
Result struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := cdpCommand(connection, &commandID, "Runtime.evaluate", map[string]any{
|
||||
"expression": "location.origin", "returnByValue": true,
|
||||
}, ¤tOrigin, nil); err != nil || currentOrigin.Result.Value != douyinOrigin {
|
||||
return restrictedBrowserResponse{}, errors.New("restricted browser origin changed")
|
||||
}
|
||||
encodedURL, _ := json.Marshal(target)
|
||||
expression := `(async()=>{const r=await fetch(` + string(encodedURL) + `,{credentials:"include",redirect:"error"});` +
|
||||
`if(!r.body)return {status:r.status,body:"",too_large:false};const q=r.body.getReader(),d=new TextDecoder();let n=0,b="";` +
|
||||
`for(;;){const x=await q.read();if(x.done)break;if(n+x.value.byteLength>=` + strconv.Itoa(douyinResponseLimit) +
|
||||
`){await q.cancel();return {too_large:true};}n+=x.value.byteLength;b+=d.decode(x.value,{stream:true});}` +
|
||||
`b+=d.decode();return {status:r.status,body:b,too_large:false};})()`
|
||||
var evaluated struct {
|
||||
Result struct {
|
||||
Value struct {
|
||||
Status int `json:"status"`
|
||||
Body string `json:"body"`
|
||||
TooLarge bool `json:"too_large"`
|
||||
} `json:"value"`
|
||||
} `json:"result"`
|
||||
ExceptionDetails json.RawMessage `json:"exceptionDetails"`
|
||||
}
|
||||
if err := cdpCommand(connection, &commandID, "Runtime.evaluate", map[string]any{
|
||||
"expression": expression, "awaitPromise": true, "returnByValue": true,
|
||||
}, &evaluated, nil); err != nil || len(evaluated.ExceptionDetails) != 0 || evaluated.Result.Value.TooLarge ||
|
||||
evaluated.Result.Value.Status < 200 || evaluated.Result.Value.Status > 599 ||
|
||||
(evaluated.Result.Value.Status >= 300 && evaluated.Result.Value.Status < 400) {
|
||||
return restrictedBrowserResponse{}, errors.New("restricted browser fetch failed")
|
||||
}
|
||||
body := evaluated.Result.Value.Body
|
||||
return restrictedBrowserResponse{Status: evaluated.Result.Value.Status, Body: body, Challenge: detectDouyinChallenge(evaluated.Result.Value.Status, body)}, nil
|
||||
}
|
||||
|
||||
func (browser cdpBrowser) connect(ctx context.Context, alias string) (*websocket.Conn, error) {
|
||||
base := "http://" + namePrefix + alias + ":9222"
|
||||
if browser.endpoint != nil {
|
||||
base = browser.endpoint(alias)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/list", nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("restricted browser unavailable")
|
||||
}
|
||||
client := http.Client{Timeout: browserControlTimeout}
|
||||
if browser.client != nil {
|
||||
client = *browser.client
|
||||
}
|
||||
if client.Timeout <= 0 {
|
||||
client.Timeout = browserControlTimeout
|
||||
}
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, errors.New("restricted browser unavailable")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var targets []struct {
|
||||
Type string `json:"type"`
|
||||
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("restricted browser unavailable")
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, (64<<10)+1))
|
||||
if err != nil || len(body) > 64<<10 {
|
||||
return nil, errors.New("restricted browser unavailable")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
if decoder.Decode(&targets) != nil || len(targets) > 32 {
|
||||
return nil, errors.New("restricted browser unavailable")
|
||||
}
|
||||
var trailing json.RawMessage
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("restricted browser unavailable")
|
||||
}
|
||||
baseURL, _ := url.Parse(base)
|
||||
pageTarget := ""
|
||||
for _, target := range targets {
|
||||
if target.Type != "page" {
|
||||
continue
|
||||
}
|
||||
if pageTarget != "" {
|
||||
return nil, errors.New("restricted browser unavailable")
|
||||
}
|
||||
websocketURL, parseErr := url.Parse(target.WebSocketDebuggerURL)
|
||||
if parseErr != nil || websocketURL.Scheme != "ws" || !strings.HasPrefix(websocketURL.Path, "/devtools/page/") {
|
||||
return nil, errors.New("restricted browser unavailable")
|
||||
}
|
||||
if websocketURL.Hostname() == "localhost" || websocketURL.Hostname() == "127.0.0.1" || websocketURL.Hostname() == "::1" {
|
||||
websocketURL.Host = baseURL.Host
|
||||
}
|
||||
if websocketURL.Host != baseURL.Host {
|
||||
return nil, errors.New("restricted browser unavailable")
|
||||
}
|
||||
pageTarget = websocketURL.String()
|
||||
}
|
||||
if pageTarget == "" {
|
||||
return nil, errors.New("restricted browser unavailable")
|
||||
}
|
||||
config, err := websocket.NewConfig(pageTarget, "devtools://devtools")
|
||||
if err != nil {
|
||||
return nil, errors.New("restricted browser unavailable")
|
||||
}
|
||||
connection, err := config.DialContext(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.New("restricted browser unavailable")
|
||||
}
|
||||
deadline := time.Now().Add(browserControlTimeout)
|
||||
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
||||
deadline = contextDeadline
|
||||
}
|
||||
_ = connection.SetDeadline(deadline)
|
||||
return connection, nil
|
||||
}
|
||||
|
||||
type cdpMessage struct {
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error json.RawMessage `json:"error"`
|
||||
}
|
||||
|
||||
func cdpCommand(connection *websocket.Conn, commandID *int, method string, parameters any, output any, events *[]cdpMessage) error {
|
||||
*commandID = *commandID + 1
|
||||
if err := websocket.JSON.Send(connection, map[string]any{"id": *commandID, "method": method, "params": parameters}); err != nil {
|
||||
return errors.New("restricted browser command failed")
|
||||
}
|
||||
for range 128 {
|
||||
var reply cdpMessage
|
||||
if err := websocket.JSON.Receive(connection, &reply); err != nil {
|
||||
return errors.New("restricted browser command failed")
|
||||
}
|
||||
if reply.ID != *commandID {
|
||||
if events != nil && reply.ID == 0 && reply.Method != "" {
|
||||
*events = append(*events, reply)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(reply.Error) != 0 || len(reply.Result) == 0 || bytes.Equal(bytes.TrimSpace(reply.Result), []byte("null")) {
|
||||
return errors.New("restricted browser command failed")
|
||||
}
|
||||
if output != nil && json.Unmarshal(reply.Result, output) != nil {
|
||||
return errors.New("restricted browser command failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errors.New("restricted browser command failed")
|
||||
}
|
||||
|
||||
func waitForDouyinPage(ctx context.Context, connection *websocket.Conn, commandID *int, frameID, loaderID string, events []cdpMessage) error {
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
||||
deadline = contextDeadline
|
||||
}
|
||||
_ = connection.SetReadDeadline(deadline)
|
||||
for attempts := 0; attempts < 256; attempts++ {
|
||||
var event cdpMessage
|
||||
if len(events) != 0 {
|
||||
event, events = events[0], events[1:]
|
||||
} else if websocket.JSON.Receive(connection, &event) != nil {
|
||||
return errors.New("restricted browser navigation failed")
|
||||
}
|
||||
if event.Method != "Page.lifecycleEvent" {
|
||||
continue
|
||||
}
|
||||
var lifecycle struct {
|
||||
FrameID string `json:"frameId"`
|
||||
LoaderID string `json:"loaderId"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if json.Unmarshal(event.Params, &lifecycle) != nil || lifecycle.FrameID != frameID || lifecycle.LoaderID != loaderID || lifecycle.Name != "load" {
|
||||
continue
|
||||
}
|
||||
var evaluated struct {
|
||||
Result struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := cdpCommand(connection, commandID, "Runtime.evaluate", map[string]any{
|
||||
"expression": "location.origin", "returnByValue": true,
|
||||
}, &evaluated, nil); err != nil || evaluated.Result.Value != douyinOrigin {
|
||||
return errors.New("restricted browser navigation failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errors.New("restricted browser navigation failed")
|
||||
}
|
||||
|
||||
func detectDouyinChallenge(status int, body string) douyin.Challenge {
|
||||
lower := strings.ToLower(body)
|
||||
if status == http.StatusPreconditionFailed || strings.Contains(lower, "captcha") || strings.Contains(lower, "verify_center_decision_conf") {
|
||||
return douyin.ChallengeCaptcha
|
||||
}
|
||||
if strings.Contains(lower, "device_challenge") || strings.Contains(lower, "device verification") {
|
||||
return douyin.ChallengeDevice
|
||||
}
|
||||
return douyin.ChallengeNone
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/douyin"
|
||||
)
|
||||
|
||||
func TestCDPBrowserRealNavigationIsolation(t *testing.T) {
|
||||
if os.Getenv("CREATORHUB_REAL_CDP_TEST") != "1" {
|
||||
t.Skip("set CREATORHUB_REAL_CDP_TEST=1 to run against local Chrome")
|
||||
}
|
||||
chrome, err := exec.LookPath("google-chrome")
|
||||
if err != nil {
|
||||
t.Skip("google-chrome is unavailable")
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
phase := "redirect"
|
||||
release := make(chan struct{})
|
||||
mainRequests := make(chan string, 4)
|
||||
loginRequests := make(chan string, 1)
|
||||
subdomainRequests := make(chan string, 1)
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
host := request.Host
|
||||
if name, _, splitErr := net.SplitHostPort(host); splitErr == nil {
|
||||
host = name
|
||||
}
|
||||
switch host {
|
||||
case "login.douyin.com":
|
||||
loginRequests <- request.Header.Get("Cookie")
|
||||
_, _ = response.Write([]byte("redirected"))
|
||||
case "api.douyin.com":
|
||||
subdomainRequests <- request.Header.Get("Cookie")
|
||||
_, _ = response.Write([]byte("ok"))
|
||||
case "www.douyin.com":
|
||||
if request.URL.Path == "/" {
|
||||
mainRequests <- request.Header.Get("Cookie")
|
||||
}
|
||||
mu.Lock()
|
||||
currentPhase, currentRelease := phase, release
|
||||
mu.Unlock()
|
||||
if currentPhase == "redirect" {
|
||||
http.Redirect(response, request, "https://login.douyin.com/landing", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if currentPhase == "blocked" {
|
||||
<-currentRelease
|
||||
}
|
||||
response.Header().Set("Content-Type", "text/html")
|
||||
_, _ = response.Write([]byte(`<script>addEventListener("load",()=>setTimeout(()=>fetch("https://api.douyin.com/probe"),300))</script>`))
|
||||
default:
|
||||
response.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
allowedHosts := map[string]bool{"www.douyin.com": true, "login.douyin.com": true, "api.douyin.com": true}
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
host := request.Host
|
||||
if name, _, splitErr := net.SplitHostPort(host); splitErr == nil {
|
||||
host = name
|
||||
}
|
||||
if request.Method != http.MethodConnect || !allowedHosts[host] {
|
||||
response.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
upstream, dialErr := net.Dial("tcp", server.Listener.Addr().String())
|
||||
if dialErr != nil {
|
||||
response.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
client, _, hijackErr := response.(http.Hijacker).Hijack()
|
||||
if hijackErr != nil {
|
||||
upstream.Close()
|
||||
return
|
||||
}
|
||||
_, _ = client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
|
||||
go func() {
|
||||
_, _ = io.Copy(upstream, client)
|
||||
_ = upstream.Close()
|
||||
}()
|
||||
_, _ = io.Copy(client, upstream)
|
||||
_ = client.Close()
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
|
||||
profile := t.TempDir()
|
||||
command := exec.Command(chrome, "--headless=new", "--no-sandbox", "--disable-gpu", "--disable-background-networking", "--disable-quic",
|
||||
"--disable-dev-shm-usage", "--no-first-run", "--no-default-browser-check", "--password-store=basic", "--use-mock-keychain",
|
||||
"--ignore-certificate-errors", "--proxy-server="+proxy.URL,
|
||||
"--remote-debugging-address=127.0.0.1", "--remote-debugging-port=0", "--remote-allow-origins=*",
|
||||
"--user-data-dir="+profile, "about:blank")
|
||||
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := command.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL)
|
||||
_ = command.Wait()
|
||||
})
|
||||
|
||||
var debugPort string
|
||||
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(25 * time.Millisecond) {
|
||||
content, readErr := os.ReadFile(filepath.Join(profile, "DevToolsActivePort"))
|
||||
if readErr == nil {
|
||||
debugPort = strings.SplitN(string(content), "\n", 2)[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
if debugPort == "" {
|
||||
t.Fatal("Chrome did not expose a DevTools port")
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
browser := cdpBrowser{endpoint: func(string) string { return "http://127.0.0.1:" + debugPort }}
|
||||
cookie := []douyin.Cookie{{Name: "sessionid", Value: "fake-secret", Domain: ".douyin.com", Path: "/", Secure: true}}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
redirectErr := browser.SetCookies(ctx, "account-a", cookie)
|
||||
if redirectErr == nil {
|
||||
cancel()
|
||||
t.Fatal("accepted a redirected navigation")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case got := <-loginRequests:
|
||||
if strings.Contains(got, "fake-secret") {
|
||||
t.Fatal("cookie leaked during redirected navigation")
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("redirect target was not reached: %v", redirectErr)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
phase = "success"
|
||||
mu.Unlock()
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 15*time.Second)
|
||||
if err := browser.SetCookies(ctx, "account-a", cookie); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-mainRequests:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("successful navigation was not observed")
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
phase = "blocked"
|
||||
release = make(chan struct{})
|
||||
currentRelease := release
|
||||
mu.Unlock()
|
||||
defer func() {
|
||||
select {
|
||||
case <-currentRelease:
|
||||
default:
|
||||
close(currentRelease)
|
||||
}
|
||||
}()
|
||||
done := make(chan error, 1)
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 15*time.Second)
|
||||
go func() { done <- browser.SetCookies(ctx, "account-a", cookie) }()
|
||||
select {
|
||||
case got := <-mainRequests:
|
||||
if strings.Contains(got, "fake-secret") {
|
||||
cancel()
|
||||
t.Fatal("cookie leaked during navigation")
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
cancel()
|
||||
t.Fatal("blocked navigation was not observed")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
cancel()
|
||||
t.Fatalf("navigation completed before its loader: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
close(currentRelease)
|
||||
if err := <-done; err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case got := <-subdomainRequests:
|
||||
if strings.Contains(got, "fake-secret") {
|
||||
t.Fatal("host-only cookie leaked to a subdomain")
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("subdomain probe did not run")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/douyin"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
type fakeRestrictedBrowser struct {
|
||||
cookies []douyin.Cookie
|
||||
urls []string
|
||||
response restrictedBrowserResponse
|
||||
after func()
|
||||
}
|
||||
|
||||
func (browser *fakeRestrictedBrowser) SetCookies(_ context.Context, _ string, cookies []douyin.Cookie) error {
|
||||
browser.cookies = cookies
|
||||
if browser.after != nil {
|
||||
browser.after()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (browser *fakeRestrictedBrowser) Get(_ context.Context, _ string, target string) (restrictedBrowserResponse, error) {
|
||||
browser.urls = append(browser.urls, target)
|
||||
if browser.after != nil {
|
||||
browser.after()
|
||||
}
|
||||
return browser.response, nil
|
||||
}
|
||||
|
||||
func TestGatewayRestrictedDouyinContract(t *testing.T) {
|
||||
labels := map[string]string{
|
||||
managedLabel: "true", idLabel: "account-a", bindingVersionLabel: "2", networkIDLabel: "network-a", networkExitLabel: "exit-a",
|
||||
}
|
||||
self, _ := os.Hostname()
|
||||
var stateMu sync.Mutex
|
||||
containerNetworks := map[string]string{"creatorhub_browser-account-a": "network-a"}
|
||||
runtimeAttached := true
|
||||
server := httptest.NewServer(withAliasReservations(self, func(response http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/containers/" + namePrefix + "account-a/json":
|
||||
stateMu.Lock()
|
||||
labelCopy, networkCopy := map[string]string{}, map[string]any{}
|
||||
for key, value := range labels {
|
||||
labelCopy[key] = value
|
||||
}
|
||||
for name, id := range containerNetworks {
|
||||
networkCopy[name] = map[string]string{"NetworkID": id}
|
||||
}
|
||||
stateMu.Unlock()
|
||||
_ = json.NewEncoder(response).Encode(map[string]any{"Id": "runtime-a", "Config": map[string]any{"Labels": labelCopy},
|
||||
"NetworkSettings": map[string]any{"Networks": networkCopy}})
|
||||
case "/networks/network-a":
|
||||
stateMu.Lock()
|
||||
members := map[string]any{self: map[string]string{"Name": self, "IPv4Address": "127.0.0.1/8"}}
|
||||
if runtimeAttached {
|
||||
members["runtime-a"] = map[string]string{"Name": namePrefix + "account-a", "IPv4Address": "127.0.0.2/8"}
|
||||
}
|
||||
stateMu.Unlock()
|
||||
_ = json.NewEncoder(response).Encode(map[string]any{
|
||||
"Id": "network-a", "Name": "creatorhub_browser-account-a", "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
|
||||
"Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a", bindingVersionLabel: "2"},
|
||||
"Containers": members,
|
||||
})
|
||||
default:
|
||||
response.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
|
||||
browser := &fakeRestrictedBrowser{response: restrictedBrowserResponse{Status: 200, Body: `{"status_code":0}`, Challenge: douyin.ChallengeNone}}
|
||||
app := newGatewayWithBrowser(docker, "creatorhub_browser", testToken, self, browser)
|
||||
generation := `"binding_version":2,"runtime_id":"runtime-a","network_id":"network-a","network_exit_id":"exit-a"`
|
||||
|
||||
cookieBody := `{` + generation + `,"cookies":[{"name":"sessionid","value":"private-session","domain":".douyin.com","path":"/"}]}`
|
||||
response, err := app.Test(authed(http.MethodPost, "/v1/browsers/account-a/douyin/cookies", strings.NewReader(cookieBody)))
|
||||
if err != nil || response.StatusCode != http.StatusNoContent || len(browser.cookies) != 1 || browser.cookies[0].Value != "private-session" {
|
||||
t.Fatalf("set cookies failed: status=%d cookies=%#v err=%v", response.StatusCode, browser.cookies, err)
|
||||
}
|
||||
response.Body.Close()
|
||||
|
||||
identityURL := "https://www.douyin.com" + douyinIdentityPath
|
||||
getBody := `{` + generation + `,"url":"` + identityURL + `"}`
|
||||
response, err = app.Test(authed(http.MethodPost, "/v1/browsers/account-a/douyin/get", strings.NewReader(getBody)))
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if err != nil || response.StatusCode != http.StatusOK || len(browser.urls) != 1 || browser.urls[0] != identityURL ||
|
||||
!strings.Contains(string(body), `\"status_code\":0`) || strings.Contains(string(body), "private-session") {
|
||||
t.Fatalf("get failed or leaked cookies: status=%d urls=%#v body=%s err=%v", response.StatusCode, browser.urls, body, err)
|
||||
}
|
||||
|
||||
for name, request := range map[string]*http.Request{
|
||||
"unauthenticated": httptest.NewRequest(http.MethodPost, "/v1/browsers/account-a/douyin/get", strings.NewReader(getBody)),
|
||||
"invalid account": authed(http.MethodPost, "/v1/browsers/AccountA/douyin/get", strings.NewReader(getBody)),
|
||||
"generic URL": authed(http.MethodPost, "/v1/browsers/account-a/douyin/get", strings.NewReader(`{`+generation+`,"url":"https://example.com/"}`)),
|
||||
"generic CDP": authed(http.MethodPost, "/v1/browsers/account-a/douyin/get", strings.NewReader(`{`+generation+`,"url":"`+identityURL+`","method":"Runtime.evaluate"}`)),
|
||||
"trailing null": authed(http.MethodPost, "/v1/browsers/account-a/douyin/get", strings.NewReader(getBody+`null`)),
|
||||
"stale generation": authed(http.MethodPost, "/v1/browsers/account-a/douyin/get", strings.NewReader(`{"binding_version":1,"runtime_id":"runtime-a","network_id":"network-a","network_exit_id":"exit-a","url":"`+identityURL+`"}`)),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
before := len(browser.urls)
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
want := http.StatusBadRequest
|
||||
if name == "unauthenticated" {
|
||||
want = http.StatusUnauthorized
|
||||
} else if name == "stale generation" {
|
||||
want = http.StatusConflict
|
||||
}
|
||||
if response.StatusCode != want || len(browser.urls) != before {
|
||||
t.Fatalf("status=%d want=%d calls=%d want=%d", response.StatusCode, want, len(browser.urls), before)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stateMu.Lock()
|
||||
runtimeAttached = false
|
||||
stateMu.Unlock()
|
||||
before := len(browser.urls)
|
||||
response, err = app.Test(authed(http.MethodPost, "/v1/browsers/account-a/douyin/get", strings.NewReader(getBody)))
|
||||
if err != nil || response.StatusCode != http.StatusConflict || len(browser.urls) != before {
|
||||
t.Fatalf("wrong network membership reached browser: status=%d calls=%d want=%d err=%v", response.StatusCode, len(browser.urls), before, err)
|
||||
}
|
||||
response.Body.Close()
|
||||
stateMu.Lock()
|
||||
runtimeAttached = true
|
||||
stateMu.Unlock()
|
||||
|
||||
browser.after = func() {
|
||||
stateMu.Lock()
|
||||
containerNetworks["other-tenant"] = "network-b"
|
||||
stateMu.Unlock()
|
||||
}
|
||||
response, err = app.Test(authed(http.MethodPost, "/v1/browsers/account-a/douyin/cookies", strings.NewReader(cookieBody)))
|
||||
if err != nil || response.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("post-operation cross-network attachment was accepted: status=%d err=%v", response.StatusCode, err)
|
||||
}
|
||||
response.Body.Close()
|
||||
stateMu.Lock()
|
||||
delete(containerNetworks, "other-tenant")
|
||||
stateMu.Unlock()
|
||||
|
||||
browser.after = func() {
|
||||
stateMu.Lock()
|
||||
labels[networkIDLabel] = "network-replaced"
|
||||
stateMu.Unlock()
|
||||
}
|
||||
response, err = app.Test(authed(http.MethodPost, "/v1/browsers/account-a/douyin/cookies", strings.NewReader(cookieBody)))
|
||||
if err != nil || response.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("post-operation generation replacement was accepted: status=%d err=%v", response.StatusCode, err)
|
||||
}
|
||||
response.Body.Close()
|
||||
}
|
||||
|
||||
func TestCDPBrowserUsesOnlyNarrowCommands(t *testing.T) {
|
||||
var server *httptest.Server
|
||||
var mu sync.Mutex
|
||||
methods := []string{}
|
||||
secretSeen := false
|
||||
cookieNames := []string{}
|
||||
cookiesHostOnly := true
|
||||
setCookieCalls := 0
|
||||
pageOrigin := douyinOrigin
|
||||
onlyOldLoader := false
|
||||
fetchMode := "ok"
|
||||
fetchExpression := ""
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/json/list", func(response http.ResponseWriter, request *http.Request) {
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/devtools/page/one"
|
||||
_ = json.NewEncoder(response).Encode([]map[string]string{{"type": "page", "webSocketDebuggerUrl": wsURL}})
|
||||
})
|
||||
mux.Handle("/devtools/page/one", websocket.Server{
|
||||
Handshake: func(*websocket.Config, *http.Request) error { return nil },
|
||||
Handler: func(connection *websocket.Conn) {
|
||||
for {
|
||||
var command struct {
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if websocket.JSON.Receive(connection, &command) != nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
methods = append(methods, command.Method)
|
||||
secretSeen = secretSeen || strings.Contains(string(command.Params), "private-session")
|
||||
result := any(map[string]any{})
|
||||
var beforeReply, afterReply []map[string]any
|
||||
switch command.Method {
|
||||
case "Network.clearBrowserCookies":
|
||||
cookieNames = nil
|
||||
case "Network.setCookies":
|
||||
var params struct {
|
||||
Cookies []map[string]any `json:"cookies"`
|
||||
}
|
||||
_ = json.Unmarshal(command.Params, ¶ms)
|
||||
setCookieCalls++
|
||||
cookieNames = cookieNames[:0]
|
||||
for _, cookie := range params.Cookies {
|
||||
cookieNames = append(cookieNames, cookie["name"].(string))
|
||||
_, hasDomain := cookie["domain"]
|
||||
cookiesHostOnly = cookiesHostOnly && !hasDomain && cookie["url"] == douyinOriginURL
|
||||
}
|
||||
case "Page.navigate":
|
||||
result = map[string]any{"frameId": "frame-new", "loaderId": "loader-new"}
|
||||
beforeReply = append(beforeReply, map[string]any{"method": "Page.lifecycleEvent", "params": map[string]any{
|
||||
"frameId": "frame-old", "loaderId": "loader-old", "name": "load",
|
||||
}})
|
||||
if !onlyOldLoader {
|
||||
afterReply = append(afterReply, map[string]any{"method": "Page.lifecycleEvent", "params": map[string]any{
|
||||
"frameId": "frame-new", "loaderId": "loader-new", "name": "load",
|
||||
}})
|
||||
}
|
||||
case "Runtime.evaluate":
|
||||
var params struct {
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
_ = json.Unmarshal(command.Params, ¶ms)
|
||||
if params.Expression == "location.origin" {
|
||||
result = map[string]any{"result": map[string]any{"value": pageOrigin}}
|
||||
} else {
|
||||
fetchExpression = params.Expression
|
||||
value := map[string]any{"status": 412, "body": `{"captcha":true}`, "too_large": false}
|
||||
if fetchMode == "redirect" {
|
||||
value = map[string]any{"status": 302, "body": "", "too_large": false}
|
||||
} else if fetchMode == "too_large" {
|
||||
value = map[string]any{"too_large": true}
|
||||
}
|
||||
result = map[string]any{"result": map[string]any{"value": map[string]any{
|
||||
"status": value["status"], "body": value["body"], "too_large": value["too_large"],
|
||||
}}}
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
for _, event := range beforeReply {
|
||||
_ = websocket.JSON.Send(connection, event)
|
||||
}
|
||||
_ = websocket.JSON.Send(connection, map[string]any{"id": command.ID, "result": result})
|
||||
for _, event := range afterReply {
|
||||
_ = websocket.JSON.Send(connection, event)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
server = httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
browser := cdpBrowser{endpoint: func(string) string { return server.URL }, client: server.Client()}
|
||||
if err := browser.SetCookies(context.Background(), "account-a", []douyin.Cookie{{
|
||||
Name: "old_auth", Value: "old-session", Domain: ".douyin.com", Path: "/",
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := browser.SetCookies(context.Background(), "account-a", []douyin.Cookie{{
|
||||
Name: "sessionid", Value: "private-session", Domain: ".douyin.com", Path: "/",
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := browser.Get(context.Background(), "account-a", "https://www.douyin.com"+douyinIdentityPath)
|
||||
if err != nil || result.Status != 412 || result.Challenge != douyin.ChallengeCaptcha {
|
||||
t.Fatalf("unexpected CDP response: %#v err=%v", result, err)
|
||||
}
|
||||
mu.Lock()
|
||||
if !secretSeen || !cookiesHostOnly || strings.Join(cookieNames, ",") != "sessionid" ||
|
||||
strings.Join(methods, ",") != "Network.enable,Network.clearBrowserCookies,Page.enable,Page.setLifecycleEventsEnabled,Page.navigate,Runtime.evaluate,Network.setCookies,"+
|
||||
"Network.enable,Network.clearBrowserCookies,Page.enable,Page.setLifecycleEventsEnabled,Page.navigate,Runtime.evaluate,Network.setCookies,Runtime.evaluate,Runtime.evaluate" ||
|
||||
!strings.Contains(fetchExpression, `redirect:"error"`) || !strings.Contains(fetchExpression, "getReader()") ||
|
||||
!strings.Contains(fetchExpression, "q.cancel()") || !strings.Contains(fetchExpression, ">=1048576") || strings.Contains(fetchExpression, "r.text()") {
|
||||
mu.Unlock()
|
||||
t.Fatalf("unexpected CDP contract: methods=%#v cookies=%#v secret_seen=%v expression=%s", methods, cookieNames, secretSeen, fetchExpression)
|
||||
}
|
||||
pageOrigin = "https://login.douyin.com"
|
||||
setCookiesBeforeRedirect := setCookieCalls
|
||||
mu.Unlock()
|
||||
if err := browser.SetCookies(context.Background(), "account-a", []douyin.Cookie{{
|
||||
Name: "sessionid", Value: "private-session", Domain: ".douyin.com", Path: "/",
|
||||
}}); err == nil {
|
||||
t.Fatal("accepted navigation redirected to a Douyin subdomain")
|
||||
}
|
||||
mu.Lock()
|
||||
if setCookieCalls != setCookiesBeforeRedirect {
|
||||
mu.Unlock()
|
||||
t.Fatal("set cookies before rejecting redirected navigation")
|
||||
}
|
||||
pageOrigin, fetchMode = douyinOrigin, "redirect"
|
||||
mu.Unlock()
|
||||
if _, err := browser.Get(context.Background(), "account-a", "https://www.douyin.com"+douyinIdentityPath); err == nil {
|
||||
t.Fatal("accepted a redirected fetch")
|
||||
}
|
||||
mu.Lock()
|
||||
fetchMode = "too_large"
|
||||
mu.Unlock()
|
||||
if _, err := browser.Get(context.Background(), "account-a", "https://www.douyin.com"+douyinIdentityPath); err == nil {
|
||||
t.Fatal("accepted a response at the 1 MiB limit")
|
||||
}
|
||||
mu.Lock()
|
||||
onlyOldLoader = true
|
||||
setCookiesBeforeOldLoader := setCookieCalls
|
||||
mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := browser.SetCookies(ctx, "account-a", []douyin.Cookie{{
|
||||
Name: "sessionid", Value: "private-session", Domain: ".douyin.com", Path: "/",
|
||||
}}); err == nil {
|
||||
t.Fatal("accepted an old page load event for the new navigation")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if setCookieCalls != setCookiesBeforeOldLoader {
|
||||
t.Fatal("set cookies before the new loader completed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDPDiscoveryDoesNotFollowRedirects(t *testing.T) {
|
||||
redirected := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path == "/json/list" {
|
||||
http.Redirect(response, request, "/redirected", http.StatusFound)
|
||||
return
|
||||
}
|
||||
redirected++
|
||||
response.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
browser := cdpBrowser{endpoint: func(string) string { return server.URL }, client: server.Client()}
|
||||
if _, err := browser.connect(context.Background(), "account-a"); err == nil || redirected != 0 {
|
||||
t.Fatalf("discovery redirect was followed: redirected=%d err=%v", redirected, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDPDiscoveryRequiresOnePage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
_ = json.NewEncoder(response).Encode([]map[string]string{
|
||||
{"type": "page", "webSocketDebuggerUrl": "ws://localhost/devtools/page/one"},
|
||||
{"type": "page", "webSocketDebuggerUrl": "ws://localhost/devtools/page/two"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
browser := cdpBrowser{endpoint: func(string) string { return server.URL }, client: server.Client()}
|
||||
if _, err := browser.connect(context.Background(), "account-a"); err == nil {
|
||||
t.Fatal("accepted a profile with multiple page targets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDPDiscoveryRequiresOneJSONValue(t *testing.T) {
|
||||
for name, suffix := range map[string]string{
|
||||
"null": "null", "other value": `{}`, "garbage": "garbage", "oversized": strings.Repeat(" ", 64<<10), "whitespace": " \n\t",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
websocketAttempts := 0
|
||||
var server *httptest.Server
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/json/list", func(response http.ResponseWriter, request *http.Request) {
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/devtools/page/one"
|
||||
_, _ = response.Write([]byte(`[{"type":"page","webSocketDebuggerUrl":"` + wsURL + `"}]` + suffix))
|
||||
})
|
||||
mux.Handle("/devtools/page/one", websocket.Server{
|
||||
Handshake: func(*websocket.Config, *http.Request) error {
|
||||
websocketAttempts++
|
||||
return nil
|
||||
},
|
||||
Handler: func(connection *websocket.Conn) {},
|
||||
})
|
||||
server = httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
connection, err := (cdpBrowser{endpoint: func(string) string { return server.URL }, client: server.Client()}).connect(context.Background(), "account-a")
|
||||
if connection != nil {
|
||||
connection.Close()
|
||||
}
|
||||
valid := name == "whitespace"
|
||||
wantAttempts := 0
|
||||
if valid {
|
||||
wantAttempts = 1
|
||||
}
|
||||
if (err == nil) != valid || websocketAttempts != wantAttempts {
|
||||
t.Fatalf("err=%v websocket attempts=%d", err, websocketAttempts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDouyinURLContract(t *testing.T) {
|
||||
for target, want := range map[string]bool{
|
||||
"https://www.douyin.com" + douyinIdentityPath: true,
|
||||
"https://www.douyin.com" + douyinWorksPath + "?sec_user_id=sec-a&count=20&max_cursor=0": true,
|
||||
"https://www.douyin.com" + douyinWorksPath + "?sec_user_id=sec-a&count=20&max_cursor=1": false,
|
||||
"https://www.douyin.com" + douyinWorksPath + "?sec_user_id=sec-a&count=20&max_cursor=0&method=publish": false,
|
||||
"https://www.douyin.com/aweme/v1/web/commit/item/": false,
|
||||
} {
|
||||
if got := validDouyinURL(target); got != want {
|
||||
t.Fatalf("validDouyinURL(%q)=%v want=%v", target, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,7 @@ type gateway struct {
|
||||
network string
|
||||
self string
|
||||
token string
|
||||
browser restrictedBrowser
|
||||
proxies *memoryProxyRegistry
|
||||
locks *dockerAliasReservations
|
||||
}
|
||||
@@ -276,8 +277,12 @@ func newGateway(client dockerClient, network, token string) *fiber.App {
|
||||
}
|
||||
|
||||
func newGatewayWithSelf(client dockerClient, network, token, self string) *fiber.App {
|
||||
return newGatewayWithBrowser(client, network, token, self, cdpBrowser{})
|
||||
}
|
||||
|
||||
func newGatewayWithBrowser(client dockerClient, network, token, self string, browser restrictedBrowser) *fiber.App {
|
||||
api := gateway{docker: client, network: network, self: self, token: token, proxies: newMemoryProxyRegistry(),
|
||||
locks: &dockerAliasReservations{docker: client, self: self}}
|
||||
locks: &dockerAliasReservations{docker: client, self: self}, browser: browser}
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "CreatorHub Docker gateway",
|
||||
BodyLimit: 1 << 20,
|
||||
@@ -300,6 +305,8 @@ func newGatewayWithSelf(client dockerClient, network, token, self string) *fiber
|
||||
app.Get("/v1/browsers", api.list)
|
||||
app.Post("/v1/browsers", api.create)
|
||||
app.Post("/v1/browsers/:id/proxy", api.restoreProxy)
|
||||
app.Post("/v1/browsers/:id/douyin/cookies", api.setDouyinCookies)
|
||||
app.Post("/v1/browsers/:id/douyin/get", api.getDouyin)
|
||||
app.Post("/v1/browsers/:id/:action", api.changeState)
|
||||
app.Delete("/v1/browsers/:id", api.remove)
|
||||
return app
|
||||
@@ -901,33 +908,47 @@ func (api gateway) requireGeneration(id string, input generationRequest) (string
|
||||
}
|
||||
|
||||
func (api gateway) managedContainer(id string) (string, map[string]string, error) {
|
||||
runtimeID, labels, _, err := api.managedContainerState(id)
|
||||
return runtimeID, labels, err
|
||||
}
|
||||
|
||||
func (api gateway) managedContainerState(id string) (string, map[string]string, map[string]string, error) {
|
||||
if !runtimeIDPattern.MatchString(id) {
|
||||
return "", nil, errInvalidRuntimeID
|
||||
return "", nil, nil, errInvalidRuntimeID
|
||||
}
|
||||
result, err := api.docker.request(http.MethodGet, "/containers/"+url.PathEscape(namePrefix+id)+"/json", nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, nil, err
|
||||
}
|
||||
defer result.Body.Close()
|
||||
if result.StatusCode == http.StatusNotFound {
|
||||
return "", nil, os.ErrNotExist
|
||||
return "", nil, nil, os.ErrNotExist
|
||||
}
|
||||
if result.StatusCode != http.StatusOK {
|
||||
return "", nil, fmt.Errorf("Docker inspect returned %s", result.Status)
|
||||
return "", nil, nil, fmt.Errorf("Docker inspect returned %s", result.Status)
|
||||
}
|
||||
var inspected struct {
|
||||
ID string `json:"Id"`
|
||||
Config struct {
|
||||
Labels map[string]string `json:"Labels"`
|
||||
} `json:"Config"`
|
||||
NetworkSettings struct {
|
||||
Networks map[string]struct {
|
||||
NetworkID string `json:"NetworkID"`
|
||||
} `json:"Networks"`
|
||||
} `json:"NetworkSettings"`
|
||||
}
|
||||
if err := json.NewDecoder(result.Body).Decode(&inspected); err != nil {
|
||||
return "", nil, fmt.Errorf("decode Docker inspect: %w", err)
|
||||
return "", nil, nil, fmt.Errorf("decode Docker inspect: %w", err)
|
||||
}
|
||||
if inspected.Config.Labels[managedLabel] != "true" || inspected.Config.Labels[idLabel] != id {
|
||||
return "", nil, errUnmanagedContainer
|
||||
return "", nil, nil, errUnmanagedContainer
|
||||
}
|
||||
return inspected.ID, inspected.Config.Labels, nil
|
||||
networks := make(map[string]string, len(inspected.NetworkSettings.Networks))
|
||||
for name, network := range inspected.NetworkSettings.Networks {
|
||||
networks[name] = network.NetworkID
|
||||
}
|
||||
return inspected.ID, inspected.Config.Labels, networks, nil
|
||||
}
|
||||
|
||||
// pullIfMissing 在镜像不在本地时从远端仓库拉取;镜像缺失属于可恢复错误,调用方可直接重试。
|
||||
|
||||
@@ -8,6 +8,7 @@ require (
|
||||
github.com/sirupsen/logrus v1.10.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/net v0.57.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -37,7 +38,6 @@ require (
|
||||
github.com/valyala/fasthttp v1.73.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
package douyin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
StateSucceeded = "succeeded"
|
||||
StatePolicyHold = "policy_hold"
|
||||
StateNeedsConfirmation = "needs_confirmation"
|
||||
|
||||
ReasonAuthInvalid = "douyin_auth_invalid"
|
||||
ReasonForbidden = "douyin_forbidden"
|
||||
ReasonRateLimited = "douyin_rate_limited"
|
||||
ReasonChallenge = "douyin_challenge"
|
||||
ReasonUnknown = "douyin_result_unknown"
|
||||
ReasonIdentityMatch = "douyin_identity_mismatch"
|
||||
ReasonSucceeded = "douyin_sync_succeeded"
|
||||
|
||||
identityEndpoint = "https://www.douyin.com/aweme/v1/web/user/profile/self/"
|
||||
worksEndpoint = "https://www.douyin.com/aweme/v1/web/aweme/post/"
|
||||
)
|
||||
|
||||
var (
|
||||
keyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$`)
|
||||
credentialKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}/[A-Za-z0-9][A-Za-z0-9._/-]{0,126}$`)
|
||||
)
|
||||
|
||||
var ErrInvalid = errors.New("invalid douyin connector input")
|
||||
|
||||
type Challenge string
|
||||
|
||||
const (
|
||||
ChallengeNone Challenge = ""
|
||||
ChallengeCaptcha Challenge = "captcha"
|
||||
ChallengeDevice Challenge = "device"
|
||||
)
|
||||
|
||||
type Cookie struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Domain string `json:"domain"`
|
||||
Path string `json:"path"`
|
||||
Secure bool `json:"secure,omitempty"`
|
||||
HTTPOnly bool `json:"http_only,omitempty"`
|
||||
SameSite string `json:"same_site,omitempty"`
|
||||
Expires float64 `json:"expires,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Status int
|
||||
Body []byte
|
||||
Challenge Challenge
|
||||
}
|
||||
|
||||
// Browser is the deliberately narrow contract the restricted browser control
|
||||
// plane must implement. It does not permit arbitrary CDP commands.
|
||||
type Browser interface {
|
||||
SetCookies(context.Context, []Cookie) error
|
||||
Get(context.Context, string) (Response, error)
|
||||
}
|
||||
|
||||
type SecretReference struct {
|
||||
Provider string
|
||||
Key string
|
||||
}
|
||||
|
||||
type SecretResolver interface {
|
||||
Resolve(context.Context, SecretReference) ([]byte, error)
|
||||
}
|
||||
|
||||
type Work struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
DiggCount int64 `json:"digg_count"`
|
||||
CommentCount int64 `json:"comment_count"`
|
||||
ShareCount int64 `json:"share_count"`
|
||||
PlayCount int64 `json:"play_count"`
|
||||
}
|
||||
|
||||
type Evidence struct {
|
||||
Phase string `json:"phase"`
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
IdentityVerified bool `json:"identity_verified"`
|
||||
WorksSeen int `json:"works_seen,omitempty"`
|
||||
HasMore bool `json:"has_more,omitempty"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
State string `json:"state"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Evidence Evidence `json:"evidence"`
|
||||
}
|
||||
|
||||
// Store owns both persistence/audit and fail-closed account/runtime handling.
|
||||
// Hold must pause the account and stop its existing bound runtime without retry.
|
||||
type Store interface {
|
||||
Complete(context.Context, string, []Work, Evidence) error
|
||||
Hold(context.Context, string, string, string, Evidence) error
|
||||
}
|
||||
|
||||
type Connector struct {
|
||||
Browser Browser
|
||||
Secrets SecretResolver
|
||||
Store Store
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
AccountID string
|
||||
PlatformAccountKey string
|
||||
Credential SecretReference
|
||||
}
|
||||
|
||||
func (connector Connector) Sync(ctx context.Context, request Request) (Result, error) {
|
||||
if connector.Browser == nil || connector.Secrets == nil || connector.Store == nil || !keyPattern.MatchString(request.AccountID) ||
|
||||
!keyPattern.MatchString(request.PlatformAccountKey) ||
|
||||
(request.Credential.Provider != "os_keyring" && request.Credential.Provider != "secret_manager") ||
|
||||
!credentialKeyPattern.MatchString(request.Credential.Key) {
|
||||
return Result{}, ErrInvalid
|
||||
}
|
||||
credential, err := connector.Secrets.Resolve(ctx, request.Credential)
|
||||
if err != nil {
|
||||
return connector.stop(ctx, request.AccountID, StatePolicyHold, ReasonAuthInvalid, Evidence{Phase: "login"})
|
||||
}
|
||||
cookies, err := parseCredential(credential)
|
||||
if err != nil {
|
||||
return connector.stop(ctx, request.AccountID, StatePolicyHold, ReasonAuthInvalid, Evidence{Phase: "login"})
|
||||
}
|
||||
if err := connector.Browser.SetCookies(ctx, cookies); err != nil {
|
||||
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, Evidence{Phase: "login"})
|
||||
}
|
||||
|
||||
identityResponse, err := connector.Browser.Get(ctx, identityEndpoint)
|
||||
if err != nil {
|
||||
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, Evidence{Phase: "identity"})
|
||||
}
|
||||
if state, reason := classify(identityResponse); state != "" {
|
||||
return connector.stop(ctx, request.AccountID, state, reason, Evidence{Phase: "identity", HTTPStatus: identityResponse.Status})
|
||||
}
|
||||
identity, ok := parseIdentity(identityResponse.Body)
|
||||
if !ok {
|
||||
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown,
|
||||
Evidence{Phase: "identity", HTTPStatus: identityResponse.Status})
|
||||
}
|
||||
if request.PlatformAccountKey != identity.User.UID && request.PlatformAccountKey != identity.User.SecUID &&
|
||||
request.PlatformAccountKey != identity.User.UniqueID {
|
||||
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonIdentityMatch,
|
||||
Evidence{Phase: "identity", HTTPStatus: identityResponse.Status})
|
||||
}
|
||||
|
||||
query := url.Values{"sec_user_id": {identity.User.SecUID}, "count": {"20"}, "max_cursor": {"0"}}
|
||||
worksResponse, err := connector.Browser.Get(ctx, worksEndpoint+"?"+query.Encode())
|
||||
if err != nil {
|
||||
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown,
|
||||
Evidence{Phase: "works", IdentityVerified: true})
|
||||
}
|
||||
if state, reason := classify(worksResponse); state != "" {
|
||||
return connector.stop(ctx, request.AccountID, state, reason,
|
||||
Evidence{Phase: "works", HTTPStatus: worksResponse.Status, IdentityVerified: true})
|
||||
}
|
||||
works, hasMore, ok := parseWorks(worksResponse.Body)
|
||||
evidence := Evidence{Phase: "works", HTTPStatus: worksResponse.Status, IdentityVerified: true, WorksSeen: len(works), HasMore: hasMore}
|
||||
if !ok {
|
||||
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, evidence)
|
||||
}
|
||||
if err := connector.Store.Complete(ctx, request.AccountID, works, evidence); err != nil {
|
||||
result, holdErr := connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, evidence)
|
||||
return result, errors.Join(err, holdErr)
|
||||
}
|
||||
return Result{State: StateSucceeded, ReasonCode: ReasonSucceeded, Evidence: evidence}, nil
|
||||
}
|
||||
|
||||
func (connector Connector) stop(ctx context.Context, accountID, state, reason string, evidence Evidence) (Result, error) {
|
||||
result := Result{State: state, ReasonCode: reason, Evidence: evidence}
|
||||
holdContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
return result, connector.Store.Hold(holdContext, accountID, state, reason, evidence)
|
||||
}
|
||||
|
||||
func classify(response Response) (string, string) {
|
||||
switch response.Challenge {
|
||||
case ChallengeCaptcha, ChallengeDevice:
|
||||
return StateNeedsConfirmation, ReasonChallenge
|
||||
case ChallengeNone:
|
||||
default:
|
||||
return StateNeedsConfirmation, ReasonUnknown
|
||||
}
|
||||
switch response.Status {
|
||||
case http.StatusUnauthorized:
|
||||
return StatePolicyHold, ReasonAuthInvalid
|
||||
case http.StatusForbidden:
|
||||
return StatePolicyHold, ReasonForbidden
|
||||
case http.StatusTooManyRequests:
|
||||
return StatePolicyHold, ReasonRateLimited
|
||||
case http.StatusOK:
|
||||
return "", ""
|
||||
default:
|
||||
return StateNeedsConfirmation, ReasonUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func parseCredential(raw []byte) ([]Cookie, error) {
|
||||
if len(raw) == 0 || len(raw) > 64<<10 {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
var bundle struct {
|
||||
Cookies []Cookie `json:"cookies"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&bundle); err != nil || len(bundle.Cookies) == 0 || len(bundle.Cookies) > 64 {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
var trailing json.RawMessage
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
for index := range bundle.Cookies {
|
||||
cookie := &bundle.Cookies[index]
|
||||
cookie.Domain = strings.ToLower(strings.TrimSpace(cookie.Domain))
|
||||
if cookie.Path == "" {
|
||||
cookie.Path = "/"
|
||||
}
|
||||
if cookie.Name == "" || len(cookie.Name) > 256 || len(cookie.Value) > 4096 || len(cookie.Domain) > 256 || len(cookie.Path) > 256 ||
|
||||
cookie.Expires < 0 || strings.ContainsAny(cookie.Name, ";\r\n\x00") || strings.ContainsAny(cookie.Value, ";\r\n\x00") ||
|
||||
(cookie.Domain != "douyin.com" && !strings.HasSuffix(cookie.Domain, ".douyin.com")) ||
|
||||
!strings.HasPrefix(cookie.Path, "/") || strings.ContainsAny(cookie.Path, ";\r\n\x00") ||
|
||||
(cookie.SameSite != "" && cookie.SameSite != "Lax" &&
|
||||
cookie.SameSite != "Strict" && cookie.SameSite != "None") {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
}
|
||||
return bundle.Cookies, nil
|
||||
}
|
||||
|
||||
type identityEnvelope struct {
|
||||
StatusCode *int `json:"status_code"`
|
||||
User *struct {
|
||||
UID string `json:"uid"`
|
||||
SecUID string `json:"sec_uid"`
|
||||
UniqueID string `json:"unique_id"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
func parseIdentity(body []byte) (identityEnvelope, bool) {
|
||||
var identity identityEnvelope
|
||||
if len(body) > 1<<20 || json.Unmarshal(body, &identity) != nil || identity.StatusCode == nil || *identity.StatusCode != 0 || identity.User == nil ||
|
||||
!keyPattern.MatchString(identity.User.UID) || !keyPattern.MatchString(identity.User.SecUID) ||
|
||||
(identity.User.UniqueID != "" && !keyPattern.MatchString(identity.User.UniqueID)) {
|
||||
return identityEnvelope{}, false
|
||||
}
|
||||
return identity, true
|
||||
}
|
||||
|
||||
type worksEnvelope struct {
|
||||
StatusCode *int `json:"status_code"`
|
||||
HasMore *bool `json:"has_more"`
|
||||
Works []struct {
|
||||
ID string `json:"aweme_id"`
|
||||
Description string `json:"desc"`
|
||||
CreatedAt *int64 `json:"create_time"`
|
||||
Statistics *struct {
|
||||
DiggCount *int64 `json:"digg_count"`
|
||||
CommentCount *int64 `json:"comment_count"`
|
||||
ShareCount *int64 `json:"share_count"`
|
||||
PlayCount *int64 `json:"play_count"`
|
||||
} `json:"statistics"`
|
||||
} `json:"aweme_list"`
|
||||
}
|
||||
|
||||
func parseWorks(body []byte) ([]Work, bool, bool) {
|
||||
var envelope worksEnvelope
|
||||
if len(body) > 4<<20 || json.Unmarshal(body, &envelope) != nil || envelope.StatusCode == nil || *envelope.StatusCode != 0 ||
|
||||
envelope.HasMore == nil || envelope.Works == nil || len(envelope.Works) > 20 {
|
||||
return nil, false, false
|
||||
}
|
||||
works := make([]Work, 0, len(envelope.Works))
|
||||
seen := make(map[string]bool, len(envelope.Works))
|
||||
for _, candidate := range envelope.Works {
|
||||
if !keyPattern.MatchString(candidate.ID) || seen[candidate.ID] || candidate.CreatedAt == nil || *candidate.CreatedAt <= 0 ||
|
||||
candidate.Statistics == nil || candidate.Statistics.DiggCount == nil || candidate.Statistics.CommentCount == nil ||
|
||||
candidate.Statistics.ShareCount == nil || candidate.Statistics.PlayCount == nil ||
|
||||
utf8.RuneCountInString(candidate.Description) > 4096 || *candidate.Statistics.DiggCount < 0 ||
|
||||
*candidate.Statistics.CommentCount < 0 || *candidate.Statistics.ShareCount < 0 || *candidate.Statistics.PlayCount < 0 {
|
||||
return nil, false, false
|
||||
}
|
||||
seen[candidate.ID] = true
|
||||
works = append(works, Work{ID: candidate.ID, Description: candidate.Description, CreatedAt: *candidate.CreatedAt,
|
||||
DiggCount: *candidate.Statistics.DiggCount, CommentCount: *candidate.Statistics.CommentCount,
|
||||
ShareCount: *candidate.Statistics.ShareCount, PlayCount: *candidate.Statistics.PlayCount})
|
||||
}
|
||||
return works, *envelope.HasMore, true
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package douyin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const credential = `{"cookies":[{"name":"sessionid","value":"private-session","domain":".douyin.com","path":"/","secure":true,"http_only":true,"same_site":"Lax"}]}`
|
||||
|
||||
var secretReference = SecretReference{Provider: "os_keyring", Key: "creatorhub/account-a"}
|
||||
|
||||
type fakeSecrets struct {
|
||||
value []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (secrets fakeSecrets) Resolve(_ context.Context, _ SecretReference) ([]byte, error) {
|
||||
return secrets.value, secrets.err
|
||||
}
|
||||
|
||||
type fakeBrowser struct {
|
||||
responses []Response
|
||||
err error
|
||||
cookies []Cookie
|
||||
urls []string
|
||||
}
|
||||
|
||||
func (browser *fakeBrowser) SetCookies(_ context.Context, cookies []Cookie) error {
|
||||
browser.cookies = cookies
|
||||
return browser.err
|
||||
}
|
||||
|
||||
func (browser *fakeBrowser) Get(_ context.Context, target string) (Response, error) {
|
||||
browser.urls = append(browser.urls, target)
|
||||
if browser.err != nil {
|
||||
return Response{}, browser.err
|
||||
}
|
||||
response := browser.responses[0]
|
||||
browser.responses = browser.responses[1:]
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
works []Work
|
||||
holds []Result
|
||||
completeCalls int
|
||||
holdContextErr error
|
||||
completeErr error
|
||||
holdErr error
|
||||
}
|
||||
|
||||
func (store *fakeStore) Complete(_ context.Context, _ string, works []Work, _ Evidence) error {
|
||||
store.completeCalls++
|
||||
store.works = works
|
||||
return store.completeErr
|
||||
}
|
||||
|
||||
func (store *fakeStore) Hold(ctx context.Context, _ string, state, reason string, evidence Evidence) error {
|
||||
store.holdContextErr = ctx.Err()
|
||||
store.holds = append(store.holds, Result{State: state, ReasonCode: reason, Evidence: evidence})
|
||||
return store.holdErr
|
||||
}
|
||||
|
||||
func identityBody(uid, secUID, uniqueID string) []byte {
|
||||
body, _ := json.Marshal(map[string]any{"status_code": 0, "user": map[string]string{
|
||||
"uid": uid, "sec_uid": secUID, "unique_id": uniqueID,
|
||||
}})
|
||||
return body
|
||||
}
|
||||
|
||||
func TestSyncLogsInVerifiesIdentityAndReadsOwnWorks(t *testing.T) {
|
||||
browser := &fakeBrowser{responses: []Response{
|
||||
{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")},
|
||||
{Status: 200, Body: []byte(`{"status_code":0,"has_more":true,"aweme_list":[{"aweme_id":"work-1","desc":"hello","create_time":123,"statistics":{"digg_count":4,"comment_count":3,"share_count":2,"play_count":1}}]}`)},
|
||||
}}
|
||||
store := &fakeStore{}
|
||||
result, err := (Connector{Browser: browser, Secrets: fakeSecrets{value: []byte(credential)}, Store: store}).Sync(context.Background(), Request{
|
||||
AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: secretReference,
|
||||
})
|
||||
if err != nil || result.State != StateSucceeded || !result.Evidence.IdentityVerified || result.Evidence.WorksSeen != 1 || !result.Evidence.HasMore {
|
||||
t.Fatalf("unexpected result: %#v err=%v", result, err)
|
||||
}
|
||||
if len(browser.cookies) != 1 || browser.cookies[0].Value != "private-session" || len(browser.urls) != 2 ||
|
||||
browser.urls[0] != identityEndpoint || !strings.Contains(browser.urls[1], "sec_user_id=sec-a") {
|
||||
t.Fatalf("connector did not use the bound browser session: cookies=%#v urls=%#v", browser.cookies, browser.urls)
|
||||
}
|
||||
if len(store.works) != 1 || store.works[0].ID != "work-1" || store.works[0].PlayCount != 1 || len(store.holds) != 0 {
|
||||
t.Fatalf("unexpected persisted works or hold: works=%#v holds=%#v", store.works, store.holds)
|
||||
}
|
||||
encoded, _ := json.Marshal(result)
|
||||
if strings.Contains(string(encoded), "private-session") {
|
||||
t.Fatalf("audit result leaked credential: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncMapsRiskSignalsAndNeverRetries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response Response
|
||||
state string
|
||||
reason string
|
||||
}{
|
||||
{name: "authentication invalid", response: Response{Status: 401}, state: StatePolicyHold, reason: ReasonAuthInvalid},
|
||||
{name: "forbidden", response: Response{Status: 403}, state: StatePolicyHold, reason: ReasonForbidden},
|
||||
{name: "rate limited", response: Response{Status: 429}, state: StatePolicyHold, reason: ReasonRateLimited},
|
||||
{name: "captcha", response: Response{Status: 200, Challenge: ChallengeCaptcha}, state: StateNeedsConfirmation, reason: ReasonChallenge},
|
||||
{name: "device challenge", response: Response{Status: 200, Challenge: ChallengeDevice}, state: StateNeedsConfirmation, reason: ReasonChallenge},
|
||||
{name: "unknown status", response: Response{Status: 502}, state: StateNeedsConfirmation, reason: ReasonUnknown},
|
||||
{name: "unknown challenge", response: Response{Status: 200, Challenge: "future"}, state: StateNeedsConfirmation, reason: ReasonUnknown},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
browser := &fakeBrowser{responses: []Response{test.response}}
|
||||
store := &fakeStore{}
|
||||
result, err := (Connector{Browser: browser, Secrets: fakeSecrets{value: []byte(credential)}, Store: store}).Sync(context.Background(), Request{
|
||||
AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: secretReference,
|
||||
})
|
||||
if err != nil || result.State != test.state || result.ReasonCode != test.reason || len(store.holds) != 1 {
|
||||
t.Fatalf("unexpected stop: result=%#v holds=%#v err=%v", result, store.holds, err)
|
||||
}
|
||||
if len(browser.urls) != 1 {
|
||||
t.Fatalf("risk response was retried: %#v", browser.urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncFailsClosedOnIdentityAndUnknownResults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
browser *fakeBrowser
|
||||
key string
|
||||
reason string
|
||||
phase string
|
||||
}{
|
||||
{name: "identity mismatch", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}}}, key: "another", reason: ReasonIdentityMatch, phase: "identity"},
|
||||
{name: "malformed identity", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: []byte(`{"status_code":0}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "identity"},
|
||||
{name: "identity status missing", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: []byte(`{"user":{"uid":"uid-a","sec_uid":"sec-a","unique_id":"handle-a"}}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "identity"},
|
||||
{name: "browser failure", browser: &fakeBrowser{err: errors.New("transport details must stay internal")}, key: "sec-a", reason: ReasonUnknown, phase: "login"},
|
||||
{name: "malformed works", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"aweme_list":[{"aweme_id":""}]}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
{name: "works list missing", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"has_more":false}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
{name: "works has_more missing", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"aweme_list":[]}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
{name: "work create_time missing", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"aweme_list":[{"aweme_id":"work-1","statistics":{"digg_count":0,"comment_count":0,"share_count":0,"play_count":0}}]}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
{name: "work create_time null", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"aweme_list":[{"aweme_id":"work-1","create_time":null,"statistics":{"digg_count":0,"comment_count":0,"share_count":0,"play_count":0}}]}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
{name: "work statistics missing", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"aweme_list":[{"aweme_id":"work-1","create_time":1}]}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
{name: "work statistics null", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"aweme_list":[{"aweme_id":"work-1","create_time":1,"statistics":null}]}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
{name: "work statistic missing", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"aweme_list":[{"aweme_id":"work-1","create_time":1,"statistics":{"digg_count":0,"comment_count":0,"share_count":0}}]}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
{name: "work statistic null", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"aweme_list":[{"aweme_id":"work-1","create_time":1,"statistics":{"digg_count":0,"comment_count":0,"share_count":0,"play_count":null}}]}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
result, err := (Connector{Browser: test.browser, Secrets: fakeSecrets{value: []byte(credential)}, Store: store}).Sync(context.Background(), Request{
|
||||
AccountID: "account-a", PlatformAccountKey: test.key, Credential: secretReference,
|
||||
})
|
||||
if err != nil || result.State != StateNeedsConfirmation || result.ReasonCode != test.reason || result.Evidence.Phase != test.phase || len(store.holds) != 1 || store.completeCalls != 0 {
|
||||
t.Fatalf("unexpected fail-closed result: %#v holds=%#v err=%v", result, store.holds, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRejectsInvalidCredentialWithoutLeakingIt(t *testing.T) {
|
||||
browser := &fakeBrowser{}
|
||||
store := &fakeStore{}
|
||||
result, err := (Connector{Browser: browser, Secrets: fakeSecrets{value: []byte(`{"cookies":[{"name":"sessionid","value":"secret","domain":"evil.example"}]}`)}, Store: store}).Sync(context.Background(), Request{
|
||||
AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: secretReference,
|
||||
})
|
||||
if err != nil || result.State != StatePolicyHold || result.ReasonCode != ReasonAuthInvalid || len(browser.urls) != 0 || len(browser.cookies) != 0 || len(store.holds) != 1 {
|
||||
t.Fatalf("unexpected invalid credential result: %#v browser=%#v holds=%#v err=%v", result, browser, store.holds, err)
|
||||
}
|
||||
encoded, _ := json.Marshal(result)
|
||||
if strings.Contains(string(encoded), "secret") || strings.Contains(string(encoded), "evil") {
|
||||
t.Fatalf("stop evidence leaked credential: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncStopsWhenSecretReferenceCannotResolve(t *testing.T) {
|
||||
browser := &fakeBrowser{}
|
||||
store := &fakeStore{}
|
||||
result, err := (Connector{Browser: browser, Secrets: fakeSecrets{err: errors.New("secret unavailable")}, Store: store}).Sync(context.Background(), Request{
|
||||
AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: secretReference,
|
||||
})
|
||||
if err != nil || result.State != StatePolicyHold || result.ReasonCode != ReasonAuthInvalid || len(browser.urls) != 0 || len(store.holds) != 1 {
|
||||
t.Fatalf("unavailable secret did not fail closed: result=%#v browser=%#v holds=%#v err=%v", result, browser, store.holds, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncFailsClosedWhenPersistenceIsUnknown(t *testing.T) {
|
||||
browser := &fakeBrowser{responses: []Response{
|
||||
{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")},
|
||||
{Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"aweme_list":[]}`)},
|
||||
}}
|
||||
store := &fakeStore{completeErr: errors.New("database result unknown")}
|
||||
result, err := (Connector{Browser: browser, Secrets: fakeSecrets{value: []byte(credential)}, Store: store}).Sync(context.Background(), Request{
|
||||
AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: secretReference,
|
||||
})
|
||||
if err == nil || result.State != StateNeedsConfirmation || result.ReasonCode != ReasonUnknown || len(store.holds) != 1 {
|
||||
t.Fatalf("persistence uncertainty did not stop: result=%#v holds=%#v err=%v", result, store.holds, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialAndWorkValidation(t *testing.T) {
|
||||
invalidCredentials := []string{
|
||||
``, `{}`, `{"cookies":[]}`, `{"cookies":[{"name":"a","value":"b","domain":".douyin.com","extra":true}]}`,
|
||||
`{"cookies":[{"name":"a;bad","value":"b","domain":".douyin.com"}]}`,
|
||||
credential + `true`, credential + `[]`, credential + `null`, credential + `garbage`,
|
||||
}
|
||||
for _, input := range invalidCredentials {
|
||||
if _, err := parseCredential([]byte(input)); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("accepted invalid credential bundle: %q", input)
|
||||
}
|
||||
}
|
||||
if _, _, ok := parseWorks([]byte(`{"status_code":0,"has_more":false,"aweme_list":[{"aweme_id":"same","desc":"a","create_time":1,"statistics":{"digg_count":0,"comment_count":0,"share_count":0,"play_count":0}},{"aweme_id":"same","desc":"b","create_time":1,"statistics":{"digg_count":0,"comment_count":0,"share_count":0,"play_count":0}}]}`)); ok {
|
||||
t.Fatal("accepted duplicate work ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncHoldsWithCancelledRequestContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
store := &fakeStore{}
|
||||
result, err := (Connector{Browser: &fakeBrowser{}, Secrets: fakeSecrets{err: context.Canceled}, Store: store}).Sync(ctx, Request{
|
||||
AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: secretReference,
|
||||
})
|
||||
if err != nil || result.State != StatePolicyHold || len(store.holds) != 1 || store.holdContextErr != nil {
|
||||
t.Fatalf("cancelled request did not durably hold: result=%#v holds=%#v context_err=%v err=%v", result, store.holds, store.holdContextErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRejectsNonSecretCredentialReference(t *testing.T) {
|
||||
_, err := (Connector{Browser: &fakeBrowser{}, Secrets: fakeSecrets{}, Store: &fakeStore{}}).Sync(context.Background(), Request{
|
||||
AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: SecretReference{Provider: "plain_text", Key: "raw-secret"},
|
||||
})
|
||||
if !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("accepted non-secret credential reference: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user