mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 15:47:54 +08:00
Compare commits
3 Commits
feat/lark-
...
feat/sidec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52dc09af95 | ||
|
|
07da0c8090 | ||
|
|
0aa9e96d18 |
@@ -4,11 +4,12 @@
|
|||||||
//go:build authsidecar
|
//go:build authsidecar
|
||||||
|
|
||||||
// Package sidecar provides a transport interceptor for the auth sidecar
|
// Package sidecar provides a transport interceptor for the auth sidecar
|
||||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
|
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an http:// or https://
|
||||||
// outgoing requests are rewritten to the sidecar address. The interceptor
|
// URL), all outgoing requests are rewritten to the sidecar address. The
|
||||||
// strips placeholder credentials, injects proxy headers, and signs each
|
// interceptor strips placeholder credentials, injects proxy headers, and
|
||||||
// request with HMAC-SHA256. No custom DialContext is needed — Go's
|
// signs each request with HMAC-SHA256. No custom DialContext is needed —
|
||||||
// standard http.Transport connects to the sidecar via plain HTTP.
|
// Go's standard http.Transport connects to the sidecar via HTTP, or via
|
||||||
|
// HTTPS (TLS) when the sidecar address is an https:// URL.
|
||||||
package sidecar
|
package sidecar
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -46,15 +47,17 @@ func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor
|
|||||||
}
|
}
|
||||||
key := os.Getenv(envvars.CliProxyKey)
|
key := os.Getenv(envvars.CliProxyKey)
|
||||||
return &Interceptor{
|
return &Interceptor{
|
||||||
key: []byte(key),
|
key: []byte(key),
|
||||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||||
|
sidecarScheme: sidecar.ProxyScheme(proxyAddr),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interceptor rewrites requests for the sidecar proxy.
|
// Interceptor rewrites requests for the sidecar proxy.
|
||||||
type Interceptor struct {
|
type Interceptor struct {
|
||||||
key []byte // HMAC signing key
|
key []byte // HMAC signing key
|
||||||
sidecarHost string // sidecar host:port for URL rewriting
|
sidecarHost string // sidecar host[:port] for URL rewriting
|
||||||
|
sidecarScheme string // "http" (same-host) or "https" (remote TLS sidecar)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
||||||
@@ -130,8 +133,13 @@ func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response,
|
|||||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||||
|
|
||||||
// 5. Rewrite URL to route through sidecar
|
// 5. Rewrite URL to route through sidecar. Scheme follows the configured
|
||||||
req.URL.Scheme = "http"
|
// proxy address: https for a remote (TLS) sidecar, http for a same-host one.
|
||||||
|
scheme := i.sidecarScheme
|
||||||
|
if scheme == "" {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
req.URL.Scheme = scheme
|
||||||
req.URL.Host = i.sidecarHost
|
req.URL.Host = i.sidecarHost
|
||||||
|
|
||||||
return nil // no post-hook needed
|
return nil // no post-hook needed
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ package sidecar
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/envvars"
|
||||||
"github.com/larksuite/cli/sidecar"
|
"github.com/larksuite/cli/sidecar"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -97,6 +99,54 @@ func TestInterceptor_PreRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestInterceptor_PreRoundTrip_HTTPS verifies that a remote (TLS) sidecar
|
||||||
|
// rewrites the request to https://<remote-host>, while still preserving the
|
||||||
|
// original target and signing the request.
|
||||||
|
func TestInterceptor_PreRoundTrip_HTTPS(t *testing.T) {
|
||||||
|
key := []byte("test-key-for-hmac-signing-32byte!")
|
||||||
|
interceptor := &Interceptor{key: key, sidecarHost: "sidecar.mycorp.com", sidecarScheme: "https"}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/im/v1/chats", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||||
|
|
||||||
|
interceptor.PreRoundTrip(req)
|
||||||
|
|
||||||
|
if req.URL.Scheme != "https" {
|
||||||
|
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "https")
|
||||||
|
}
|
||||||
|
if req.URL.Host != "sidecar.mycorp.com" {
|
||||||
|
t.Errorf("host = %q, want %q", req.URL.Host, "sidecar.mycorp.com")
|
||||||
|
}
|
||||||
|
// Original target still preserved for the sidecar to forward upstream.
|
||||||
|
if target := req.Header.Get(sidecar.HeaderProxyTarget); target != "https://open.feishu.cn" {
|
||||||
|
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
|
||||||
|
}
|
||||||
|
// Request is still signed.
|
||||||
|
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
|
||||||
|
t.Error("signature header should be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveInterceptor_HTTPSScheme pins the end-to-end env→scheme path: a
|
||||||
|
// (mixed-case) https proxy address must produce an interceptor that rewrites to
|
||||||
|
// https, never silently downgrading a remote sidecar to plaintext http.
|
||||||
|
func TestResolveInterceptor_HTTPSScheme(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAuthProxy, "HTTPS://sidecar.mycorp.com") // uppercase on purpose
|
||||||
|
t.Setenv(envvars.CliProxyKey, "key")
|
||||||
|
|
||||||
|
ic := (&Provider{}).ResolveInterceptor(context.Background())
|
||||||
|
si, ok := ic.(*Interceptor)
|
||||||
|
if !ok || si == nil {
|
||||||
|
t.Fatalf("expected *Interceptor, got %T", ic)
|
||||||
|
}
|
||||||
|
if si.sidecarScheme != "https" {
|
||||||
|
t.Errorf("sidecarScheme = %q, want %q (uppercase HTTPS must not downgrade)", si.sidecarScheme, "https")
|
||||||
|
}
|
||||||
|
if si.sidecarHost != "sidecar.mycorp.com" {
|
||||||
|
t.Errorf("sidecarHost = %q, want %q", si.sidecarHost, "sidecar.mycorp.com")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInterceptor_BotIdentity(t *testing.T) {
|
func TestInterceptor_BotIdentity(t *testing.T) {
|
||||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const (
|
|||||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||||
|
|
||||||
// Sidecar proxy (auth proxy mode)
|
// Sidecar proxy (auth proxy mode)
|
||||||
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
|
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar address http(s)://host[:port]; plaintext http is same-host only, a remote sidecar must use https. e.g. "http://127.0.0.1:16384" or "https://sidecar.mycorp.com"
|
||||||
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
||||||
|
|
||||||
// Content safety scanning mode
|
// Content safety scanning mode
|
||||||
|
|||||||
@@ -102,9 +102,6 @@ func TestResolveMarkdownAsPost(t *testing.T) {
|
|||||||
if !strings.Contains(got, `"tag":"md"`) {
|
if !strings.Contains(got, `"tag":"md"`) {
|
||||||
t.Fatalf("resolveMarkdownAsPost() = %q, want post payload", got)
|
t.Fatalf("resolveMarkdownAsPost() = %q, want post payload", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, `"tag":"text"`) {
|
|
||||||
t.Fatalf("resolveMarkdownAsPost() = %q, want segmented blank-line text paragraph", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, `#### Title`) || !strings.Contains(got, `##### Subtitle`) {
|
if !strings.Contains(got, `#### Title`) || !strings.Contains(got, `##### Subtitle`) {
|
||||||
t.Fatalf("resolveMarkdownAsPost() = %q, want optimized heading levels", got)
|
t.Fatalf("resolveMarkdownAsPost() = %q, want optimized heading levels", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -817,49 +817,25 @@ func readMp4Duration(f fileio.File, fileSize int64) int64 {
|
|||||||
// 5. Compress excess blank lines
|
// 5. Compress excess blank lines
|
||||||
// 6. Strip invalid image references (keep only img_xxx keys)
|
// 6. Strip invalid image references (keep only img_xxx keys)
|
||||||
var (
|
var (
|
||||||
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
|
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
|
||||||
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
|
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
|
||||||
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
|
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
|
||||||
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
|
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
|
||||||
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
|
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
|
||||||
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
|
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
|
||||||
reExcessNL = regexp.MustCompile(`\n{3,}`)
|
reExcessNL = regexp.MustCompile(`\n{3,}`)
|
||||||
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
|
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
|
||||||
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
|
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
|
||||||
reBlankLineSeparator = regexp.MustCompile(`\n(?:[ \t]*\n)+`)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
markdownCodeBlockPlaceholder = "___CB_"
|
|
||||||
postBlankLinePlaceholder = "\u200B"
|
|
||||||
)
|
|
||||||
|
|
||||||
type markdownPart struct {
|
|
||||||
text string
|
|
||||||
newlineCount int
|
|
||||||
isSeparator bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func protectMarkdownCodeBlocks(text string) (string, []string) {
|
|
||||||
var codeBlocks []string
|
|
||||||
protected := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
|
|
||||||
idx := len(codeBlocks)
|
|
||||||
codeBlocks = append(codeBlocks, m)
|
|
||||||
return fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, idx)
|
|
||||||
})
|
|
||||||
return protected, codeBlocks
|
|
||||||
}
|
|
||||||
|
|
||||||
func restoreMarkdownCodeBlocks(text string, codeBlocks []string) string {
|
|
||||||
restored := text
|
|
||||||
for i, block := range codeBlocks {
|
|
||||||
restored = strings.Replace(restored, fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, i), block, 1)
|
|
||||||
}
|
|
||||||
return restored
|
|
||||||
}
|
|
||||||
|
|
||||||
func optimizeMarkdownStyle(text string) string {
|
func optimizeMarkdownStyle(text string) string {
|
||||||
r, codeBlocks := protectMarkdownCodeBlocks(text)
|
const mark = "___CB_"
|
||||||
|
var codeBlocks []string
|
||||||
|
r := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
|
||||||
|
idx := len(codeBlocks)
|
||||||
|
codeBlocks = append(codeBlocks, m)
|
||||||
|
return fmt.Sprintf("%s%d___", mark, idx)
|
||||||
|
})
|
||||||
|
|
||||||
// Only downgrade when original text has H1~H3; order matters (H2~H6 first).
|
// Only downgrade when original text has H1~H3; order matters (H2~H6 first).
|
||||||
if reHasH1toH3.MatchString(text) {
|
if reHasH1toH3.MatchString(text) {
|
||||||
@@ -872,7 +848,9 @@ func optimizeMarkdownStyle(text string) string {
|
|||||||
r = reTableNoGap.ReplaceAllString(r, "$1\n\n$2")
|
r = reTableNoGap.ReplaceAllString(r, "$1\n\n$2")
|
||||||
r = reTableAfter.ReplaceAllString(r, "$1\n")
|
r = reTableAfter.ReplaceAllString(r, "$1\n")
|
||||||
|
|
||||||
r = restoreMarkdownCodeBlocks(r, codeBlocks)
|
for i, block := range codeBlocks {
|
||||||
|
r = strings.Replace(r, fmt.Sprintf("%s%d___", mark, i), block, 1)
|
||||||
|
}
|
||||||
|
|
||||||
r = reExcessNL.ReplaceAllString(r, "\n\n")
|
r = reExcessNL.ReplaceAllString(r, "\n\n")
|
||||||
|
|
||||||
@@ -891,109 +869,12 @@ func optimizeMarkdownStyle(text string) string {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldUseSegmentedPost(markdown string) bool {
|
|
||||||
protected, _ := protectMarkdownCodeBlocks(markdown)
|
|
||||||
return reBlankLineSeparator.MatchString(protected)
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitMarkdownByBlankLines(markdown string) []markdownPart {
|
|
||||||
protected, codeBlocks := protectMarkdownCodeBlocks(markdown)
|
|
||||||
locs := reBlankLineSeparator.FindAllStringIndex(protected, -1)
|
|
||||||
if len(locs) == 0 {
|
|
||||||
return []markdownPart{{text: markdown}}
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := make([]markdownPart, 0, len(locs)*2+1)
|
|
||||||
last := 0
|
|
||||||
for _, loc := range locs {
|
|
||||||
if loc[0] > last {
|
|
||||||
content := restoreMarkdownCodeBlocks(protected[last:loc[0]], codeBlocks)
|
|
||||||
if content != "" {
|
|
||||||
parts = append(parts, markdownPart{text: content})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
separator := protected[loc[0]:loc[1]]
|
|
||||||
parts = append(parts, markdownPart{
|
|
||||||
isSeparator: true,
|
|
||||||
newlineCount: strings.Count(separator, "\n"),
|
|
||||||
})
|
|
||||||
last = loc[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
if last < len(protected) {
|
|
||||||
content := restoreMarkdownCodeBlocks(protected[last:], codeBlocks)
|
|
||||||
if content != "" {
|
|
||||||
parts = append(parts, markdownPart{text: content})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return []markdownPart{{text: markdown}}
|
|
||||||
}
|
|
||||||
return parts
|
|
||||||
}
|
|
||||||
|
|
||||||
func marshalMarkdownPostContent(content [][]map[string]interface{}) string {
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
"zh_cn": map[string]interface{}{
|
|
||||||
"content": content,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
data, _ := json.Marshal(payload)
|
|
||||||
return string(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildSingleMDPost(markdown string) string {
|
|
||||||
return marshalMarkdownPostContent([][]map[string]interface{}{
|
|
||||||
{{
|
|
||||||
"tag": "md",
|
|
||||||
"text": optimizeMarkdownStyle(markdown),
|
|
||||||
}},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildSegmentedPost(markdown string) string {
|
|
||||||
parts := splitMarkdownByBlankLines(markdown)
|
|
||||||
content := make([][]map[string]interface{}, 0, len(parts))
|
|
||||||
for _, part := range parts {
|
|
||||||
if part.isSeparator {
|
|
||||||
for i := 1; i < part.newlineCount; i++ {
|
|
||||||
content = append(content, []map[string]interface{}{{
|
|
||||||
"tag": "text",
|
|
||||||
"text": postBlankLinePlaceholder,
|
|
||||||
}})
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if part.text == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
optimized := strings.Trim(optimizeMarkdownStyle(part.text), "\n")
|
|
||||||
if optimized == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
content = append(content, []map[string]interface{}{{
|
|
||||||
"tag": "md",
|
|
||||||
"text": optimized,
|
|
||||||
}})
|
|
||||||
}
|
|
||||||
if len(content) == 0 {
|
|
||||||
return buildSingleMDPost(markdown)
|
|
||||||
}
|
|
||||||
return marshalMarkdownPostContent(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildMarkdownPostContent(markdown string) string {
|
|
||||||
if shouldUseSegmentedPost(markdown) {
|
|
||||||
return buildSegmentedPost(markdown)
|
|
||||||
}
|
|
||||||
return buildSingleMDPost(markdown)
|
|
||||||
}
|
|
||||||
|
|
||||||
// wrapMarkdownAsPost wraps markdown text into Feishu post format JSON (no network).
|
// wrapMarkdownAsPost wraps markdown text into Feishu post format JSON (no network).
|
||||||
// Used by DryRun. Output may include md/text paragraphs when blank-line separators are present.
|
// Used by DryRun. Output: {"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}}
|
||||||
func wrapMarkdownAsPost(markdown string) string {
|
func wrapMarkdownAsPost(markdown string) string {
|
||||||
return buildMarkdownPostContent(markdown)
|
optimized := optimizeMarkdownStyle(markdown)
|
||||||
|
inner, _ := json.Marshal(optimized)
|
||||||
|
return `{"zh_cn":{"content":[[{"tag":"md","text":` + string(inner) + `}]]}}`
|
||||||
}
|
}
|
||||||
|
|
||||||
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\((https?://[^)\s]+)\)`)
|
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\((https?://[^)\s]+)\)`)
|
||||||
@@ -1028,7 +909,9 @@ func wrapMarkdownAsPostForDryRun(markdown string) (content, desc string) {
|
|||||||
// and wraps as post format JSON. Used by Execute (makes network calls).
|
// and wraps as post format JSON. Used by Execute (makes network calls).
|
||||||
func resolveMarkdownAsPost(ctx context.Context, runtime *common.RuntimeContext, markdown string) string {
|
func resolveMarkdownAsPost(ctx context.Context, runtime *common.RuntimeContext, markdown string) string {
|
||||||
resolved := resolveMarkdownImageURLs(ctx, runtime, markdown)
|
resolved := resolveMarkdownImageURLs(ctx, runtime, markdown)
|
||||||
return buildMarkdownPostContent(resolved)
|
optimized := optimizeMarkdownStyle(resolved)
|
||||||
|
inner, _ := json.Marshal(optimized)
|
||||||
|
return `{"zh_cn":{"content":[[{"tag":"md","text":` + string(inner) + `}]]}}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveMarkdownImageURLs finds  in markdown, downloads each URL,
|
// resolveMarkdownImageURLs finds  in markdown, downloads each URL,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package im
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -17,36 +16,6 @@ import (
|
|||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
func decodePostContentForTest(t *testing.T, raw string) []interface{} {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var payload map[string]interface{}
|
|
||||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
|
||||||
t.Fatalf("json.Unmarshal() error = %v, raw=%s", err, raw)
|
|
||||||
}
|
|
||||||
locale, _ := payload["zh_cn"].(map[string]interface{})
|
|
||||||
content, _ := locale["content"].([]interface{})
|
|
||||||
if content == nil {
|
|
||||||
t.Fatalf("post content missing: %#v", payload)
|
|
||||||
}
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodePostParagraphForTest(t *testing.T, raw string, idx int) map[string]interface{} {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
content := decodePostContentForTest(t, raw)
|
|
||||||
if idx >= len(content) {
|
|
||||||
t.Fatalf("paragraph index %d out of range, len=%d, raw=%s", idx, len(content), raw)
|
|
||||||
}
|
|
||||||
paragraph, _ := content[idx].([]interface{})
|
|
||||||
if len(paragraph) != 1 {
|
|
||||||
t.Fatalf("paragraph %d = %#v, want single node", idx, paragraph)
|
|
||||||
}
|
|
||||||
node, _ := paragraph[0].(map[string]interface{})
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeAtMentions(t *testing.T) {
|
func TestNormalizeAtMentions(t *testing.T) {
|
||||||
input := `<at id=ou_alpha/> hi <at open_id="ou_beta"> and <at user_id=ou_gamma /> and <at email="x@example.com"/>`
|
input := `<at id=ou_alpha/> hi <at open_id="ou_beta"> and <at user_id=ou_gamma /> and <at email="x@example.com"/>`
|
||||||
got := normalizeAtMentions(input)
|
got := normalizeAtMentions(input)
|
||||||
@@ -171,16 +140,6 @@ func TestWrapMarkdownAsPostForDryRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWrapMarkdownAsPostForDryRun_SegmentedBlankLines(t *testing.T) {
|
|
||||||
content, _ := wrapMarkdownAsPostForDryRun("hello\n\n")
|
|
||||||
if !strings.Contains(content, ``) {
|
|
||||||
t.Fatalf("wrapMarkdownAsPostForDryRun(segmented) content = %q, want placeholder img key", content)
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, `"tag":"text"`) {
|
|
||||||
t.Fatalf("wrapMarkdownAsPostForDryRun(segmented) content = %q, want blank-line text paragraph", content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveMediaContentWithoutUploads(t *testing.T) {
|
func TestResolveMediaContentWithoutUploads(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -375,88 +334,15 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
|
|||||||
|
|
||||||
func TestWrapMarkdownAsPost(t *testing.T) {
|
func TestWrapMarkdownAsPost(t *testing.T) {
|
||||||
got := wrapMarkdownAsPost("hello **world**")
|
got := wrapMarkdownAsPost("hello **world**")
|
||||||
content := decodePostContentForTest(t, got)
|
// Should produce valid JSON with post structure
|
||||||
if len(content) != 1 {
|
if !strings.Contains(got, `"tag":"md"`) {
|
||||||
t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content))
|
t.Fatalf("wrapMarkdownAsPost() missing md tag: %s", got)
|
||||||
}
|
}
|
||||||
node := decodePostParagraphForTest(t, got, 0)
|
if !strings.Contains(got, `"zh_cn"`) {
|
||||||
if node["tag"] != "md" {
|
t.Fatalf("wrapMarkdownAsPost() missing zh_cn: %s", got)
|
||||||
t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"])
|
|
||||||
}
|
}
|
||||||
if node["text"] != "hello **world**" {
|
if !strings.Contains(got, "hello **world**") {
|
||||||
t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**")
|
t.Fatalf("wrapMarkdownAsPost() missing content: %s", got)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShouldUseSegmentedPost(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
markdown string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{name: "single newline", markdown: "a\nb", want: false},
|
|
||||||
{name: "blank line", markdown: "a\n\nb", want: true},
|
|
||||||
{name: "blank line with spaces", markdown: "a\n \nb", want: true},
|
|
||||||
{name: "multiple blank lines", markdown: "a\n \n \n b", want: true},
|
|
||||||
{name: "blank lines inside code block only", markdown: "```go\n\n\nfmt.Println(1)\n```\nnext", want: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := shouldUseSegmentedPost(tt.markdown); got != tt.want {
|
|
||||||
t.Fatalf("shouldUseSegmentedPost(%q) = %v, want %v", tt.markdown, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrapMarkdownAsPost_SegmentedBlankLines(t *testing.T) {
|
|
||||||
got := wrapMarkdownAsPost("a\n\nb")
|
|
||||||
content := decodePostContentForTest(t, got)
|
|
||||||
if len(content) != 3 {
|
|
||||||
t.Fatalf("wrapMarkdownAsPost(a\\n\\nb) content len = %d, want 3", len(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
first := decodePostParagraphForTest(t, got, 0)
|
|
||||||
if first["tag"] != "md" || first["text"] != "a" {
|
|
||||||
t.Fatalf("first paragraph = %#v, want md/a", first)
|
|
||||||
}
|
|
||||||
|
|
||||||
second := decodePostParagraphForTest(t, got, 1)
|
|
||||||
if second["tag"] != "text" || second["text"] != postBlankLinePlaceholder {
|
|
||||||
t.Fatalf("second paragraph = %#v, want blank text placeholder", second)
|
|
||||||
}
|
|
||||||
|
|
||||||
third := decodePostParagraphForTest(t, got, 2)
|
|
||||||
if third["tag"] != "md" || third["text"] != "b" {
|
|
||||||
t.Fatalf("third paragraph = %#v, want md/b", third)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrapMarkdownAsPost_SegmentedMultipleBlankLines(t *testing.T) {
|
|
||||||
got := wrapMarkdownAsPost("a\n\n\nb")
|
|
||||||
content := decodePostContentForTest(t, got)
|
|
||||||
if len(content) != 4 {
|
|
||||||
t.Fatalf("wrapMarkdownAsPost(a\\n\\n\\nb) content len = %d, want 4", len(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 1; i <= 2; i++ {
|
|
||||||
node := decodePostParagraphForTest(t, got, i)
|
|
||||||
if node["tag"] != "text" || node["text"] != postBlankLinePlaceholder {
|
|
||||||
t.Fatalf("blank paragraph %d = %#v, want blank text placeholder", i, node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrapMarkdownAsPost_SegmentedBlankLinesWithSpaces(t *testing.T) {
|
|
||||||
got := wrapMarkdownAsPost("a\n \nb")
|
|
||||||
content := decodePostContentForTest(t, got)
|
|
||||||
if len(content) != 3 {
|
|
||||||
t.Fatalf("wrapMarkdownAsPost(a\\n \\nb) content len = %d, want 3", len(content))
|
|
||||||
}
|
|
||||||
node := decodePostParagraphForTest(t, got, 1)
|
|
||||||
if node["tag"] != "text" || node["text"] != postBlankLinePlaceholder {
|
|
||||||
t.Fatalf("middle paragraph = %#v, want blank text placeholder", node)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,11 @@ func TestValidateProxyAddr(t *testing.T) {
|
|||||||
"http://gateway.docker.internal:16384",
|
"http://gateway.docker.internal:16384",
|
||||||
// trailing slash is tolerated
|
// trailing slash is tolerated
|
||||||
"http://127.0.0.1:8080/",
|
"http://127.0.0.1:8080/",
|
||||||
|
// https: any valid host (including remote, cross-machine) is allowed
|
||||||
|
"https://127.0.0.1:16384",
|
||||||
|
"https://sidecar.mycorp.com",
|
||||||
|
"https://sidecar.mycorp.com:8443",
|
||||||
|
"https://sidecar.corp.internal:443/",
|
||||||
}
|
}
|
||||||
for _, addr := range valid {
|
for _, addr := range valid {
|
||||||
if err := ValidateProxyAddr(addr); err != nil {
|
if err := ValidateProxyAddr(addr); err != nil {
|
||||||
@@ -242,6 +247,8 @@ func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) {
|
|||||||
"http://user@127.0.0.1:16384",
|
"http://user@127.0.0.1:16384",
|
||||||
"http://user:pass@127.0.0.1:16384",
|
"http://user:pass@127.0.0.1:16384",
|
||||||
"http://127.0.0.1@attacker.com:16384",
|
"http://127.0.0.1@attacker.com:16384",
|
||||||
|
"https://x@evil.com",
|
||||||
|
"https://user:pass@sidecar.mycorp.com",
|
||||||
} {
|
} {
|
||||||
err := ValidateProxyAddr(addr)
|
err := ValidateProxyAddr(addr)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -259,23 +266,99 @@ func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestValidateProxyAddr_HTTPSRejected pins the current contract: https is
|
// TestValidateProxyAddr_HTTPSAllowed pins the contract: https addresses are
|
||||||
// rejected explicitly (not lumped into a generic "bad scheme" error) because
|
// accepted, including a remote sidecar on another machine. TLS provides
|
||||||
// the interceptor hardcodes http and would silently downgrade an https URL
|
// confidentiality over the network and the HMAC signature provides
|
||||||
// otherwise. The message must mention https so users understand why their
|
// integrity/auth, so cross-machine https is supported.
|
||||||
// perfectly-looking config is refused.
|
func TestValidateProxyAddr_HTTPSAllowed(t *testing.T) {
|
||||||
func TestValidateProxyAddr_HTTPSRejected(t *testing.T) {
|
|
||||||
for _, addr := range []string{
|
for _, addr := range []string{
|
||||||
"https://127.0.0.1:16384",
|
"https://127.0.0.1:16384", // same-host over TLS
|
||||||
"https://sidecar.corp.internal:443",
|
"https://sidecar.corp.internal:443",
|
||||||
|
"https://sidecar.mycorp.com", // remote, no explicit port
|
||||||
|
"https://sidecar.mycorp.com:8443",
|
||||||
|
} {
|
||||||
|
if err := ValidateProxyAddr(addr); err != nil {
|
||||||
|
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateProxyAddr_HTTPRemoteRejected: plaintext http to a non-same-host
|
||||||
|
// address stays rejected — a remote sidecar must use https.
|
||||||
|
func TestValidateProxyAddr_HTTPRemoteRejected(t *testing.T) {
|
||||||
|
for _, addr := range []string{
|
||||||
|
"http://sidecar.mycorp.com",
|
||||||
|
"http://sidecar.mycorp.com:8080",
|
||||||
|
"http://10.0.0.1:16384",
|
||||||
} {
|
} {
|
||||||
err := ValidateProxyAddr(addr)
|
err := ValidateProxyAddr(addr)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("ValidateProxyAddr(%q): expected error, got nil", addr)
|
t.Errorf("ValidateProxyAddr(%q): expected rejection (http remote), got nil", addr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "https") {
|
msg := err.Error()
|
||||||
t.Errorf("ValidateProxyAddr(%q): error should mention https, got: %v", addr, err)
|
if !strings.Contains(msg, "https") && !strings.Contains(msg, "same-host") && !strings.Contains(msg, "loopback") {
|
||||||
|
t.Errorf("ValidateProxyAddr(%q): error should point to https/same-host, got: %v", addr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxyScheme: scheme is https only for https:// addresses, http otherwise.
|
||||||
|
// Case-insensitive: HTTPS:// must resolve to https, otherwise a remote sidecar
|
||||||
|
// would silently downgrade to plaintext http (see ProxyScheme doc).
|
||||||
|
func TestProxyScheme(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"https://sidecar.mycorp.com": "https",
|
||||||
|
"https://127.0.0.1:16384": "https",
|
||||||
|
"http://127.0.0.1:16384": "http",
|
||||||
|
"127.0.0.1:16384": "http",
|
||||||
|
// case-insensitive scheme
|
||||||
|
"HTTPS://sidecar.mycorp.com": "https",
|
||||||
|
"Https://sidecar.mycorp.com": "https",
|
||||||
|
"HtTp://127.0.0.1:16384": "http",
|
||||||
|
}
|
||||||
|
for in, want := range tests {
|
||||||
|
if got := ProxyScheme(in); got != want {
|
||||||
|
t.Errorf("ProxyScheme(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateProxyAddr_SchemeCaseInsensitive: mixed-case scheme must follow the
|
||||||
|
// same policy as lower-case — HTTPS accepted (remote allowed), HTTP remote
|
||||||
|
// rejected — so case can't be used to bypass the plaintext same-host rule.
|
||||||
|
func TestValidateProxyAddr_SchemeCaseInsensitive(t *testing.T) {
|
||||||
|
for _, addr := range []string{"HTTPS://sidecar.mycorp.com", "Https://sidecar.corp.internal:443"} {
|
||||||
|
if err := ValidateProxyAddr(addr); err != nil {
|
||||||
|
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, addr := range []string{"HtTp://sidecar.mycorp.com", "HTTP://10.0.0.1:16384"} {
|
||||||
|
if err := ValidateProxyAddr(addr); err == nil {
|
||||||
|
t.Errorf("ValidateProxyAddr(%q): expected rejection (http remote), got nil", addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateProxyAddr_IPv6HTTPS pins IPv6 https forms.
|
||||||
|
func TestValidateProxyAddr_IPv6HTTPS(t *testing.T) {
|
||||||
|
for _, addr := range []string{"https://[::1]:443", "https://[::1]"} {
|
||||||
|
if err := ValidateProxyAddr(addr); err != nil {
|
||||||
|
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateProxyAddr_RejectsQueryFragment: a proxy address must not carry a
|
||||||
|
// query or fragment, for either scheme.
|
||||||
|
func TestValidateProxyAddr_RejectsQueryFragment(t *testing.T) {
|
||||||
|
for _, addr := range []string{
|
||||||
|
"https://sidecar.mycorp.com?x=1",
|
||||||
|
"https://sidecar.mycorp.com#frag",
|
||||||
|
"http://127.0.0.1:16384?x=1",
|
||||||
|
} {
|
||||||
|
if err := ValidateProxyAddr(addr); err == nil {
|
||||||
|
t.Errorf("ValidateProxyAddr(%q): expected rejection, got nil", addr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,6 +372,10 @@ func TestProxyHost(t *testing.T) {
|
|||||||
{"http://0.0.0.0:8080", "0.0.0.0:8080"},
|
{"http://0.0.0.0:8080", "0.0.0.0:8080"},
|
||||||
{"http://host.docker.internal:16384/", "host.docker.internal:16384"},
|
{"http://host.docker.internal:16384/", "host.docker.internal:16384"},
|
||||||
{"127.0.0.1:16384", "127.0.0.1:16384"}, // no scheme
|
{"127.0.0.1:16384", "127.0.0.1:16384"}, // no scheme
|
||||||
|
// https forms (remote sidecar)
|
||||||
|
{"https://sidecar.mycorp.com", "sidecar.mycorp.com"},
|
||||||
|
{"https://sidecar.mycorp.com:8443/", "sidecar.mycorp.com:8443"},
|
||||||
|
{"HTTPS://sidecar.mycorp.com", "sidecar.mycorp.com"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.input, func(t *testing.T) {
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
|
|
||||||
// Package sidecar defines the wire protocol shared between the CLI client
|
// Package sidecar defines the wire protocol shared between the CLI client
|
||||||
// (running inside a sandbox) and the auth sidecar proxy (running in a
|
// (running inside a sandbox) and the auth sidecar proxy (running in a
|
||||||
// trusted environment). Communication uses plain HTTP.
|
// trusted environment). Communication uses HTTP for a same-host sidecar, or
|
||||||
|
// HTTPS (TLS) for a remote sidecar.
|
||||||
package sidecar
|
package sidecar
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -103,32 +104,31 @@ func isSameHost(host string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// errNotSameHost is the shared error returned when the sidecar address does
|
// errNotSameHost is the shared error returned when a plaintext (http) sidecar
|
||||||
// not resolve to the same physical host as the sandbox. Kept in one place so
|
// address does not resolve to the same physical host as the sandbox. Kept in
|
||||||
// tests can look for a stable marker.
|
// one place so tests can look for a stable marker.
|
||||||
func errNotSameHost(addr string) error {
|
func errNotSameHost(addr string) error {
|
||||||
return fmt.Errorf("invalid proxy address %q: host must be loopback "+
|
return fmt.Errorf("invalid proxy address %q: a plaintext (http) sidecar must be "+
|
||||||
"(127.0.0.1 / ::1) or a recognized same-host alias "+
|
"loopback (127.0.0.1 / ::1) or a recognized same-host alias "+
|
||||||
"(localhost, host.docker.internal, host.containers.internal, "+
|
"(localhost, host.docker.internal, host.containers.internal, "+
|
||||||
"host.lima.internal, gateway.docker.internal). "+
|
"host.lima.internal, gateway.docker.internal). "+
|
||||||
"The sidecar must run on the same physical machine as the sandbox — "+
|
"For a remote sidecar on another machine, use an https:// address instead", addr)
|
||||||
"cross-machine deployment is not a sidecar and is not supported", addr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateProxyAddr validates the LARKSUITE_CLI_AUTH_PROXY value.
|
// ValidateProxyAddr validates the LARKSUITE_CLI_AUTH_PROXY value.
|
||||||
// Accepted formats:
|
// Accepted formats:
|
||||||
// - http://host:port
|
// - https://host[:port] (remote sidecar; cross-machine allowed)
|
||||||
// - host:port (bare address, treated as http)
|
// - http://host:port (plaintext; same-host only)
|
||||||
|
// - host:port (bare address, treated as plaintext http; same-host only)
|
||||||
//
|
//
|
||||||
// Host must be loopback or in sameHostAliases. The sidecar pattern is
|
// Scheme policy:
|
||||||
// inherently same-machine; cross-machine deployment is a different product
|
// - https:// — any valid host is allowed, including a remote central sidecar
|
||||||
// and is not supported by this feature.
|
// on another machine. TLS provides confidentiality over the untrusted
|
||||||
//
|
// network; the per-request HMAC signature provides integrity/auth.
|
||||||
// https:// is rejected because sidecar is a same-host pattern: loopback
|
// - http:// (or bare host:port) — plaintext, allowed only when the host is
|
||||||
// and virtual same-host bridges don't traverse any untrusted medium, so
|
// loopback (127.0.0.1 / ::1) or a recognized same-host alias (a virtual
|
||||||
// TLS adds no security. Cross-machine deployment is out of scope (see the
|
// same-host bridge that stays on the physical machine). For a remote
|
||||||
// host constraint above), so there is no scenario today where https
|
// sidecar, use an https:// address instead.
|
||||||
// provides a real benefit over http on loopback.
|
|
||||||
//
|
//
|
||||||
// userinfo (user:pass@) is rejected unconditionally — the sidecar protocol
|
// userinfo (user:pass@) is rejected unconditionally — the sidecar protocol
|
||||||
// does not use basic auth, and the syntactic slot exists only as a phishing
|
// does not use basic auth, and the syntactic slot exists only as a phishing
|
||||||
@@ -140,11 +140,11 @@ func ValidateProxyAddr(addr string) error {
|
|||||||
return fmt.Errorf("proxy address is empty")
|
return fmt.Errorf("proxy address is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bare host:port (no scheme) — validate as a net address.
|
// Bare host:port (no scheme) — treated as plaintext http, so same-host only.
|
||||||
if !strings.Contains(addr, "://") {
|
if !strings.Contains(addr, "://") {
|
||||||
host, port, err := net.SplitHostPort(addr)
|
host, port, err := net.SplitHostPort(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid proxy address %q: expected host:port or http://host:port", addr)
|
return fmt.Errorf("invalid proxy address %q: expected host:port or http(s)://host[:port]", addr)
|
||||||
}
|
}
|
||||||
if host == "" || port == "" {
|
if host == "" || port == "" {
|
||||||
return fmt.Errorf("invalid proxy address %q: host and port must not be empty", addr)
|
return fmt.Errorf("invalid proxy address %q: host and port must not be empty", addr)
|
||||||
@@ -159,33 +159,47 @@ func ValidateProxyAddr(addr string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid proxy address %q: %w", addr, err)
|
return fmt.Errorf("invalid proxy address %q: %w", addr, err)
|
||||||
}
|
}
|
||||||
|
// userinfo (user:pass@) is rejected unconditionally (phishing vector).
|
||||||
if u.User != nil {
|
if u.User != nil {
|
||||||
return fmt.Errorf("invalid proxy address %q: userinfo is not allowed", addr)
|
return fmt.Errorf("invalid proxy address %q: userinfo is not allowed", addr)
|
||||||
}
|
}
|
||||||
if u.Scheme == "https" {
|
|
||||||
return fmt.Errorf("invalid proxy address %q: use http:// — sidecar is "+
|
|
||||||
"same-host only (loopback or virtual same-host bridge), so TLS adds "+
|
|
||||||
"no security; cross-machine deployment is out of scope", addr)
|
|
||||||
}
|
|
||||||
if u.Scheme != "http" {
|
|
||||||
return fmt.Errorf("invalid proxy address %q: scheme must be http", addr)
|
|
||||||
}
|
|
||||||
if u.Host == "" {
|
if u.Host == "" {
|
||||||
return fmt.Errorf("invalid proxy address %q: missing host", addr)
|
return fmt.Errorf("invalid proxy address %q: missing host", addr)
|
||||||
}
|
}
|
||||||
if u.Path != "" && u.Path != "/" {
|
if u.Path != "" && u.Path != "/" {
|
||||||
return fmt.Errorf("invalid proxy address %q: path is not allowed", addr)
|
return fmt.Errorf("invalid proxy address %q: path is not allowed", addr)
|
||||||
}
|
}
|
||||||
// u.Hostname() strips the port and unwraps IPv6 brackets.
|
if u.RawQuery != "" {
|
||||||
if !isSameHost(u.Hostname()) {
|
return fmt.Errorf("invalid proxy address %q: query is not allowed", addr)
|
||||||
return errNotSameHost(addr)
|
}
|
||||||
|
if u.Fragment != "" {
|
||||||
|
return fmt.Errorf("invalid proxy address %q: fragment is not allowed", addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch u.Scheme {
|
||||||
|
case "https":
|
||||||
|
// Remote sidecar over TLS. Cross-machine is allowed: https provides
|
||||||
|
// confidentiality over the network and the per-request HMAC signature
|
||||||
|
// provides integrity/authentication, so a remote central sidecar is
|
||||||
|
// supported without exposing credentials or signing material in clear.
|
||||||
|
return nil
|
||||||
|
case "http":
|
||||||
|
// Plaintext: only safe on the same physical host (loopback or a virtual
|
||||||
|
// same-host bridge). For a remote sidecar use an https:// address.
|
||||||
|
// u.Hostname() strips the port and unwraps IPv6 brackets.
|
||||||
|
if !isSameHost(u.Hostname()) {
|
||||||
|
return errNotSameHost(addr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid proxy address %q: scheme must be http or https", addr)
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProxyHost extracts the host:port from an AUTH_PROXY URL.
|
// ProxyHost extracts the host:port from an AUTH_PROXY URL.
|
||||||
// Input is expected to be an HTTP URL like "http://127.0.0.1:16384".
|
// Input is expected to be an http:// or https:// URL like
|
||||||
// Returns the host:port portion for URL rewriting.
|
// "http://127.0.0.1:16384" or "https://sidecar.mycorp.com".
|
||||||
|
// Returns the host[:port] portion for URL rewriting.
|
||||||
func ProxyHost(authProxy string) string {
|
func ProxyHost(authProxy string) string {
|
||||||
// Strip scheme
|
// Strip scheme
|
||||||
host := authProxy
|
host := authProxy
|
||||||
@@ -196,3 +210,19 @@ func ProxyHost(authProxy string) string {
|
|||||||
host = strings.TrimRight(host, "/")
|
host = strings.TrimRight(host, "/")
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProxyScheme returns the URL scheme the CLI must use when routing to the
|
||||||
|
// sidecar: "https" for a TLS (remote) sidecar, otherwise "http" (same-host
|
||||||
|
// plaintext). Input is a value already accepted by ValidateProxyAddr.
|
||||||
|
//
|
||||||
|
// It parses the address (rather than a case-sensitive prefix check) so the
|
||||||
|
// result stays consistent with ValidateProxyAddr, which relies on url.Parse
|
||||||
|
// normalizing the scheme. Otherwise "HTTPS://host" — accepted as https by
|
||||||
|
// ValidateProxyAddr — would silently downgrade to plaintext http here,
|
||||||
|
// breaking the "remote must use TLS" boundary.
|
||||||
|
func ProxyScheme(authProxy string) string {
|
||||||
|
if u, err := url.Parse(authProxy); err == nil && strings.EqualFold(u.Scheme, "https") {
|
||||||
|
return "https"
|
||||||
|
}
|
||||||
|
return "http"
|
||||||
|
}
|
||||||
|
|||||||
@@ -114,18 +114,23 @@ export LARKSUITE_CLI_STRICT_MODE="user" # optional: lock sandbox to o
|
|||||||
|
|
||||||
**`LARKSUITE_CLI_AUTH_PROXY` constraints** — validated by the CLI on startup:
|
**`LARKSUITE_CLI_AUTH_PROXY` constraints** — validated by the CLI on startup:
|
||||||
|
|
||||||
- Scheme must be `http://` (or bare `host:port`). `https://` is rejected
|
- Scheme must be `http://` / `https://` (or bare `host:port`, treated as
|
||||||
today because the interceptor does not yet perform TLS; a future PR that
|
plaintext http).
|
||||||
wires up real TLS will relax this.
|
- `https://<any-host>` is allowed, **including a remote sidecar on another
|
||||||
- Host must be loopback (`127.0.0.1`, `::1`) or one of the recognized
|
machine**: TLS provides confidentiality over the network and the
|
||||||
same-host aliases: `localhost`, `host.docker.internal`,
|
per-request HMAC signature provides integrity/authentication.
|
||||||
`host.containers.internal`, `host.lima.internal`, `gateway.docker.internal`.
|
- Plaintext `http://` (and bare `host:port`) is allowed **only same-host**:
|
||||||
The sidecar pattern is inherently same-machine; cross-machine deployment
|
loopback (`127.0.0.1`, `::1`) or a recognized same-host alias
|
||||||
is a different product (auth broker / STS) with different security
|
(`localhost`, `host.docker.internal`, `host.containers.internal`,
|
||||||
requirements (mTLS, cert rotation, per-client keys) and is not supported
|
`host.lima.internal`, `gateway.docker.internal`). For a remote sidecar,
|
||||||
by this feature.
|
use an `https://` address.
|
||||||
- No path, query, fragment, or `user:pass@` in the URL.
|
- No path, query, fragment, or `user:pass@` in the URL.
|
||||||
|
|
||||||
|
> Note: this demo server itself terminates plain HTTP and is meant to run
|
||||||
|
> locally. A production **remote** sidecar must terminate TLS (its own
|
||||||
|
> `https://` endpoint, e.g. behind a load balancer or with a real
|
||||||
|
> certificate); the CLI-side policy above is what enables pointing at it.
|
||||||
|
|
||||||
**How auto identity detection works in sidecar mode**: on every invocation the
|
**How auto identity detection works in sidecar mode**: on every invocation the
|
||||||
CLI asks the sidecar to look up the logged-in user's `open_id` via
|
CLI asks the sidecar to look up the logged-in user's `open_id` via
|
||||||
`/open-apis/authen/v1/user_info`. If that succeeds, `--as` defaults to `user`;
|
`/open-apis/authen/v1/user_info`. If that succeeds, `--as` defaults to `user`;
|
||||||
|
|||||||
Reference in New Issue
Block a user