diff --git a/Dockerfile b/Dockerfile
index 2b4e50a3..68d1f233 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM jumpserver/lion-base:20260701_083042 AS stage-build
+FROM jumpserver/lion-base:20260707_101243 AS stage-build
ARG TARGETARCH
ARG GOPROXY=https://goproxy.io
diff --git a/go.mod b/go.mod
index 4ec364df..e8ad729d 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
github.com/gin-gonic/gin v1.11.0
github.com/go-redis/redis/v8 v8.11.5
github.com/gorilla/websocket v1.5.3
- github.com/jumpserver-dev/sdk-go v0.0.0-20260520022428-47f0d4d20dc6
+ github.com/jumpserver-dev/sdk-go v0.0.0-20260707072327-59c76e43904f
github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.47.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
diff --git a/go.sum b/go.sum
index fd36ce16..fdbd3769 100644
--- a/go.sum
+++ b/go.sum
@@ -116,6 +116,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/jumpserver-dev/sdk-go v0.0.0-20260520022428-47f0d4d20dc6 h1:K3DJEekApUoQUuVX9x6U8pvP/g7qaHy8TcCVmsIlf64=
github.com/jumpserver-dev/sdk-go v0.0.0-20260520022428-47f0d4d20dc6/go.mod h1:QmnL7BRb74/XY1nu8kWQWs6DHXt3uIaWkDCkQSRyZlM=
+github.com/jumpserver-dev/sdk-go v0.0.0-20260707072327-59c76e43904f h1:7C5PoXuDuux091WUO3IGGfvKfQt7jGYZXWTSA8JovlU=
+github.com/jumpserver-dev/sdk-go v0.0.0-20260707072327-59c76e43904f/go.mod h1:QmnL7BRb74/XY1nu8kWQWs6DHXt3uIaWkDCkQSRyZlM=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
diff --git a/pkg/session/permisson.go b/pkg/session/permisson.go
index cb26f41e..31b65d75 100644
--- a/pkg/session/permisson.go
+++ b/pkg/session/permisson.go
@@ -15,9 +15,12 @@ type ActionPermission struct {
EnableUpload bool `json:"enable_upload"`
EnableDownload bool `json:"enable_download"`
EnableShare bool `json:"enable_share"`
+
+ ClipboardPolicy *model.ClipboardPolicy `json:"clipboard_policy,omitempty"`
}
-func NewActionPermission(perm *model.Permission, connectType string) *ActionPermission {
+func NewActionPermission(perm *model.Permission, connectType string,
+ clipboardPolicy *model.ClipboardPolicy) *ActionPermission {
action := ActionPermission{
EnableConnect: perm.EnableConnect(),
EnableCopy: perm.EnableCopy(),
@@ -47,5 +50,22 @@ func NewActionPermission(perm *model.Permission, connectType string) *ActionPerm
action.EnablePaste = false
action.EnableCopy = false
}
+ action.applyClipboardPolicy(clipboardPolicy)
return &action
}
+
+func (a *ActionPermission) applyClipboardPolicy(policy *model.ClipboardPolicy) {
+ if policy == nil {
+ return
+ }
+ a.ClipboardPolicy = policy
+ a.EnableCopy = a.EnableCopy && clipboardPolicyItemEnabled(policy.Copy)
+ a.EnablePaste = a.EnablePaste && clipboardPolicyItemEnabled(policy.Paste)
+}
+
+func clipboardPolicyItemEnabled(item *model.ClipboardPolicyItem) bool {
+ if item == nil {
+ return true
+ }
+ return item.Enabled
+}
diff --git a/pkg/session/server.go b/pkg/session/server.go
index 0019cbed..81cf2204 100644
--- a/pkg/session/server.go
+++ b/pkg/session/server.go
@@ -296,7 +296,7 @@ func (s *Server) Create(ctx *gin.Context, opts ...TunnelOption) (sess TunnelSess
sess.ExpireInfo = opt.ExpireInfo
sess.Permission = &perm
sess.Account = opt.Account
- sess.ActionPerm = NewActionPermission(&perm, targetType)
+ sess.ActionPerm = NewActionPermission(&perm, targetType, opt.authInfo.ClipboardPolicy)
jmsSession := model.Session{
ID: sess.ID,
User: sess.User.String(),
diff --git a/pkg/tunnel/clipboard_policy.go b/pkg/tunnel/clipboard_policy.go
new file mode 100644
index 00000000..3ecdf9f5
--- /dev/null
+++ b/pkg/tunnel/clipboard_policy.go
@@ -0,0 +1,138 @@
+package tunnel
+
+import (
+ "encoding/base64"
+ "math"
+ "strings"
+ "unicode/utf8"
+
+ "lion/pkg/guacd"
+ "lion/pkg/session"
+)
+
+type clipboardTransfer struct {
+ index string
+ text bool
+ allow bool
+ limit int
+ size int
+}
+
+const bytesPerMegabyte = 1024 * 1024
+
+type clipboardPolicyFilter struct {
+ perm *session.ActionPermission
+ toClient map[string]*clipboardTransfer
+ toServer map[string]*clipboardTransfer
+}
+
+func newClipboardPolicyFilter(perm *session.ActionPermission) *clipboardPolicyFilter {
+ return &clipboardPolicyFilter{
+ perm: perm,
+ toClient: map[string]*clipboardTransfer{},
+ toServer: map[string]*clipboardTransfer{},
+ }
+}
+
+func (f *clipboardPolicyFilter) filterToClient(instruction *guacd.Instruction) *guacd.Instruction {
+ return f.filter(instruction, f.toClient, true)
+}
+
+func (f *clipboardPolicyFilter) filterToServer(instruction *guacd.Instruction) *guacd.Instruction {
+ return f.filter(instruction, f.toServer, false)
+}
+
+func (f *clipboardPolicyFilter) streamPolicy(toClient, text bool) (bool, int) {
+ if f == nil || f.perm == nil {
+ return true, 0
+ }
+ enabled := f.perm.EnablePaste
+ if toClient {
+ enabled = f.perm.EnableCopy
+ }
+ if f.perm.ClipboardPolicy == nil {
+ return enabled, 0
+ }
+ item := f.perm.ClipboardPolicy.Paste
+ if toClient {
+ item = f.perm.ClipboardPolicy.Copy
+ }
+ if item == nil {
+ return enabled, 0
+ }
+ if text {
+ return enabled, item.TextLimit
+ }
+ return enabled, fileSizeLimitBytes(item.FileSizeLimit)
+}
+
+func fileSizeLimitBytes(limitMB int) int {
+ if limitMB <= 0 {
+ return 0
+ }
+ if limitMB > math.MaxInt/bytesPerMegabyte {
+ return math.MaxInt
+ }
+ return limitMB * bytesPerMegabyte
+}
+
+func (f *clipboardPolicyFilter) filter(instruction *guacd.Instruction,
+ transfers map[string]*clipboardTransfer, toClient bool) *guacd.Instruction {
+ if f == nil || f.perm == nil || instruction == nil {
+ return instruction
+ }
+ switch instruction.Opcode {
+ case guacd.InstructionStreamingClipboard:
+ if len(instruction.Args) < 2 {
+ return instruction
+ }
+ text := strings.HasPrefix(instruction.Args[1], "text/")
+ allow, limit := f.streamPolicy(toClient, text)
+ transfer := &clipboardTransfer{
+ index: instruction.Args[0],
+ text: text,
+ allow: allow,
+ limit: limit,
+ }
+ transfers[transfer.index] = transfer
+ if !transfer.allow {
+ return nil
+ }
+ case guacd.InstructionStreamingBlob:
+ if len(instruction.Args) < 2 {
+ return instruction
+ }
+ transfer := transfers[instruction.Args[0]]
+ if transfer == nil {
+ return instruction
+ }
+ if !transfer.allow {
+ return nil
+ }
+ if transfer.limit > 0 {
+ blob, err := base64.StdEncoding.DecodeString(instruction.Args[1])
+ if err != nil {
+ transfer.allow = false
+ return nil
+ }
+ if transfer.text {
+ transfer.size += utf8.RuneCount(blob)
+ } else {
+ transfer.size += len(blob)
+ }
+ if transfer.size > transfer.limit {
+ transfer.allow = false
+ return nil
+ }
+ }
+ case guacd.InstructionStreamingEnd:
+ if len(instruction.Args) > 0 {
+ transfer := transfers[instruction.Args[0]]
+ delete(transfers, instruction.Args[0])
+ if transfer != nil && !transfer.allow {
+ return nil
+ }
+ }
+ }
+ return instruction
+}
diff --git a/pkg/tunnel/conn.go b/pkg/tunnel/conn.go
index cc12fb62..fbf9f1e3 100644
--- a/pkg/tunnel/conn.go
+++ b/pkg/tunnel/conn.go
@@ -59,6 +59,8 @@ type Connection struct {
inputFilter *InputStreamInterceptingFilter
+ clipboardFilter *clipboardPolicyFilter
+
done chan struct{}
traceLock sync.Mutex
@@ -119,6 +121,12 @@ func (t *Connection) readTunnelInstruction() (*guacd.Instruction, error) {
continue
}
}
+ if t.clipboardFilter != nil {
+ newInstruction = t.clipboardFilter.filterToClient(newInstruction)
+ if newInstruction == nil {
+ continue
+ }
+ }
return newInstruction, nil
}
@@ -293,6 +301,13 @@ func (t *Connection) Run(ctx *gin.Context) (err error) {
default:
}
}
+ if t.clipboardFilter != nil {
+ filtered := t.clipboardFilter.filterToServer(&ret)
+ if filtered == nil {
+ continue
+ }
+ message = []byte(filtered.String())
+ }
} else {
logger.Errorf("Session[%s] parse instruction err %s", t, err)
}
diff --git a/pkg/tunnel/server.go b/pkg/tunnel/server.go
index d2a0fe59..82d3a782 100644
--- a/pkg/tunnel/server.go
+++ b/pkg/tunnel/server.go
@@ -309,6 +309,7 @@ func (g *GuacamoleTunnelServer) Connect(ctx *gin.Context) {
}
conn.outputFilter = &outFilter
conn.inputFilter = &inputFilter
+ conn.clipboardFilter = newClipboardPolicyFilter(tunnelSession.ActionPerm)
logger.Infof("Session[%s] connect success", sessionId)
g.Cache.Add(&conn)
replayRecorder := &ReplayRecorder{
diff --git a/ui/src/components/ClipBoardText.vue b/ui/src/components/ClipBoardText.vue
index d0e30df8..f2fa0073 100644
--- a/ui/src/components/ClipBoardText.vue
+++ b/ui/src/components/ClipBoardText.vue
@@ -1,5 +1,5 @@
-
+
@@ -91,7 +119,7 @@ const maxlength = 1024 * 4;
readonly
placeholder=""
show-count
- :disabled="props.disabled"
+ :disabled="props.disabled || props.copyDisabled"
/>
diff --git a/ui/src/hooks/useGuacamoleClient.ts b/ui/src/hooks/useGuacamoleClient.ts
index c3bafe75..77164ff1 100644
--- a/ui/src/hooks/useGuacamoleClient.ts
+++ b/ui/src/hooks/useGuacamoleClient.ts
@@ -1,4 +1,4 @@
-import { ref } from 'vue';
+import { computed, ref } from 'vue';
// @ts-ignore
import Guacamole from 'guacamole-common-js';
@@ -84,6 +84,20 @@ const FileType = {
DIRECTORY: 'DIRECTORY',
};
+interface ClipboardPolicyItem {
+ text_limit?: number;
+ file_size_limit?: number;
+}
+
+interface ClipboardPolicy {
+ copy?: ClipboardPolicyItem;
+ paste?: ClipboardPolicyItem;
+}
+
+const DEFAULT_CLIPBOARD_TEXT_LIMIT = 4096;
+const BYTES_PER_MEGABYTE = 1024 * 1024;
+const getTextLength = (text: string) => Array.from(text).length;
+
export async function getSupportedImages(): Promise {
// 清空之前的结果
supportImages.length = 0;
@@ -184,6 +198,7 @@ export function useGuacamoleClient(t: any) {
const isRemoteApp = ref(false);
const isHttpProtocol = ref(false);
const remoteClipboardText = ref('');
+ const clipboardPasteTextLimit = computed(() => getClipboardTextLimit('paste'));
function connectToGuacamole(
wsUrl: string,
connectParams: Record,
@@ -402,7 +417,68 @@ export function useGuacamoleClient(t: any) {
}
sendTextToRemote(text);
}, 300);
+ const getClipboardPolicyItem = (direction: 'copy' | 'paste'): ClipboardPolicyItem | null => {
+ const policy = action_permission.value?.clipboard_policy as ClipboardPolicy | undefined;
+ return policy?.[direction] || null;
+ };
+ const getClipboardTextLimit = (direction: 'copy' | 'paste') => {
+ const limit = getClipboardPolicyItem(direction)?.text_limit || 0;
+ if (!Number.isFinite(limit) || limit <= 0 || limit > DEFAULT_CLIPBOARD_TEXT_LIMIT) {
+ return DEFAULT_CLIPBOARD_TEXT_LIMIT;
+ }
+ return limit;
+ };
+ const getClipboardFileSizeLimit = (direction: 'copy' | 'paste') => {
+ const limit = getClipboardPolicyItem(direction)?.file_size_limit || 0;
+ return Number.isFinite(limit) && limit > 0 ? limit : 0;
+ };
+ const getClipboardFileSizeLimitBytes = (direction: 'copy' | 'paste') =>
+ getClipboardFileSizeLimit(direction) * BYTES_PER_MEGABYTE;
+ const canUseClipboardDirection = (direction: 'copy' | 'paste') => {
+ return Boolean(
+ direction === 'copy'
+ ? action_permission.value?.enable_copy
+ : action_permission.value?.enable_paste,
+ );
+ };
+ const showClipboardLimitWarning = (direction: 'copy' | 'paste', limit: number) => {
+ const action = direction === 'copy' ? t('Copy') : t('Paste');
+ message.warning(`${action} ${t('ClipboardTextLimitExceeded')}: ${limit}`);
+ };
+ const showClipboardFileSizeLimitWarning = (direction: 'copy' | 'paste', limit: number) => {
+ const action = direction === 'copy' ? t('Copy') : t('Paste');
+ message.warning(`${action} ${t('ClipboardFileSizeLimitExceeded')}: ${limit}`);
+ };
+ const validateClipboardText = (direction: 'copy' | 'paste', text: string) => {
+ if (!canUseClipboardDirection(direction)) {
+ const action = direction === 'copy' ? t('Copy') : t('Paste');
+ message.warning(`${action} ${t('NoPermission')}`);
+ return false;
+ }
+ const limit = getClipboardTextLimit(direction);
+ if (limit > 0 && getTextLength(text) > limit) {
+ showClipboardLimitWarning(direction, limit);
+ return false;
+ }
+ return true;
+ };
+ const validateClipboardBlob = (direction: 'copy' | 'paste', size: number) => {
+ if (!canUseClipboardDirection(direction)) {
+ const action = direction === 'copy' ? t('Copy') : t('Paste');
+ message.warning(`${action} ${t('NoPermission')}`);
+ return false;
+ }
+ const limit = getClipboardFileSizeLimitBytes(direction);
+ if (limit > 0 && size > limit) {
+ showClipboardFileSizeLimitWarning(direction, getClipboardFileSizeLimit(direction));
+ return false;
+ }
+ return true;
+ };
const sendTextToRemote = (text: string) => {
+ if (!validateClipboardText('paste', text)) {
+ return;
+ }
const data = {
type: 'text/plain',
data: text,
@@ -625,7 +701,7 @@ export function useGuacamoleClient(t: any) {
msg = msg + ': ' + status.message;
break;
}
- message.error(msg, {duration: 10000});
+ message.error(msg, { duration: 10000 });
};
const clientStateChanged = (state: any) => {
@@ -967,6 +1043,9 @@ export function useGuacamoleClient(t: any) {
// Set clipboard contents once stream is finished
reader.onend = async () => {
console.log('clipboard received from remote: ', data);
+ if (!validateClipboardText('copy', data)) {
+ return;
+ }
remoteClipboardText.value = data;
if (navigator.clipboard) {
await navigator.clipboard.writeText(data);
@@ -982,6 +1061,9 @@ export function useGuacamoleClient(t: any) {
reader.onend = () => {
const blob = reader.getBlob();
console.log('clipboard blob received from remote: ', blob);
+ if (!validateClipboardBlob('copy', blob.size)) {
+ return;
+ }
navigator.clipboard.write(blob);
};
}
@@ -1126,6 +1208,7 @@ export function useGuacamoleClient(t: any) {
fileFsLoading,
currentGuacFsObject,
remoteClipboardText,
+ clipboardPasteTextLimit,
sendInputActive,
};
}
diff --git a/ui/src/locales/modules/en.json b/ui/src/locales/modules/en.json
index 0967ef42..086aed57 100644
--- a/ui/src/locales/modules/en.json
+++ b/ui/src/locales/modules/en.json
@@ -1 +1,7 @@
-{}
+{
+ "ClipboardTextLimitExceeded": "clipboard text exceeds the limit",
+ "ClipboardFileSizeLimitExceeded": "clipboard file exceeds the limit",
+ "Copy": "Copy",
+ "Paste": "Paste",
+ "NoPermission": "permission denied"
+}
diff --git a/ui/src/locales/modules/ja.json b/ui/src/locales/modules/ja.json
index 0967ef42..c3a7d541 100644
--- a/ui/src/locales/modules/ja.json
+++ b/ui/src/locales/modules/ja.json
@@ -1 +1,7 @@
-{}
+{
+ "ClipboardTextLimitExceeded": "クリップボードのテキストが制限を超えています",
+ "ClipboardFileSizeLimitExceeded": "クリップボードのファイルが制限を超えています",
+ "Copy": "コピー",
+ "Paste": "貼り付け",
+ "NoPermission": "権限がありません"
+}
diff --git a/ui/src/locales/modules/zh.json b/ui/src/locales/modules/zh.json
index 0967ef42..f7b04e40 100644
--- a/ui/src/locales/modules/zh.json
+++ b/ui/src/locales/modules/zh.json
@@ -1 +1,7 @@
-{}
+{
+ "ClipboardTextLimitExceeded": "剪贴板文本超过限制",
+ "ClipboardFileSizeLimitExceeded": "剪贴板文件超过限制",
+ "Copy": "复制",
+ "Paste": "粘贴",
+ "NoPermission": "无权限"
+}
diff --git a/ui/src/locales/modules/zh_Hant.json b/ui/src/locales/modules/zh_Hant.json
index 0967ef42..51c2f3b3 100644
--- a/ui/src/locales/modules/zh_Hant.json
+++ b/ui/src/locales/modules/zh_Hant.json
@@ -1 +1,7 @@
-{}
+{
+ "ClipboardTextLimitExceeded": "剪貼簿文字超過限制",
+ "ClipboardFileSizeLimitExceeded": "剪貼簿檔案超過限制",
+ "Copy": "複製",
+ "Paste": "貼上",
+ "NoPermission": "無權限"
+}
diff --git a/ui/src/views/ConnectView.vue b/ui/src/views/ConnectView.vue
index 3642a320..7613f711 100644
--- a/ui/src/views/ConnectView.vue
+++ b/ui/src/views/ConnectView.vue
@@ -10,7 +10,6 @@ import { LUNA_MESSAGE_TYPE } from '@/types/postmessage.type';
import ClipBoardText from '@/components/ClipBoardText.vue';
import SessionShare from '@/components/SessionShare/index.vue';
import FileManager from '@/components/FileManager.vue';
-import { readClipboardText } from '@/utils/clipboard';
import Osk from '@/components/Osk.vue';
import KeyboardOption from '@/components/KeyboardOption.vue';
import OtherOption from '@/components/OtherOption.vue';
@@ -43,11 +42,13 @@ const {
currentFolder,
currentFolderFiles,
hasClipboardPermission,
+ debouncedSendClipboardToRemote,
fileFsLoading,
currentGuacFsObject,
enableShare,
action_permission,
remoteClipboardText,
+ clipboardPasteTextLimit,
sendInputActive,
} = useGuacamoleClient(t);
@@ -191,14 +192,6 @@ const fileDrop = (event: any) => {
});
};
-const debouncedSendClipboardToRemote = useDebounceFn(async () => {
- const text = await readClipboardText();
- if (!text || !text.trim()) {
- return;
- }
- sendTextToRemote(text);
-}, 300);
-
onMounted(async () => {
loading.value = true;
const handLunaOpen = (message: any) => {
@@ -358,7 +351,7 @@ const scaleGuaDisplay = (value: number) => {
console.log('Scaling Guacamole display to:', value);
const newScale = value / 100; // 限制缩放范围在0.1到5之间
-// 只要是加号放大就显示滚动条
+ // 只要是加号放大就显示滚动条
if (newScale > scale.value) {
shouldEnableScroll.value = true;
} else if (newScale <= 1) {
@@ -443,11 +436,18 @@ const shouldEnableScroll = ref(false);
-
+