Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
70121fc
fix analyze amplification
ducnmm Apr 9, 2026
7fc4a77
Fix analyze quota charging and ordering
ducnmm Apr 9, 2026
973612a
Harden analyze quota and LLM output
ducnmm Apr 9, 2026
8f416da
test(security): add integration test for analyze rate-limit
hien-p Apr 9, 2026
ec57c97
fix analyze weighting
ducnmm Apr 9, 2026
f4f0748
feat: enable SEAL key-server verification across all SealClient insta…
ducnmm Apr 10, 2026
3b7d3f8
feat(security): implement MEM-23 Phase 5 Informational best practices
ducnmm Apr 13, 2026
11dd683
clean(mem-23): remove out-of-scope changes; restore MEM-11 CORS/auth,…
ducnmm Apr 14, 2026
b3f6fe6
fix(sec): add body-size limits on unauthenticated public endpoints
hien-p Apr 14, 2026
3d094ea
fix(sec): HIGH-12 — block open redirect on guest sign-in redirectUrl
hien-p Apr 14, 2026
c0299ef
chore: remove accidentally committed local review files
ducnmm Apr 14, 2026
ecb0241
Merge pull request #105 from MystenLabs/sec/body-size-limits
ducnmm Apr 14, 2026
0115c5b
Merge pull request #94 from MystenLabs/feature/mem-23-sec-phase-5-inf…
harrymove-ctrl Apr 14, 2026
2fc0472
Merge pull request #106 from MystenLabs/sec/open-redirect-fix
ducnmm Apr 14, 2026
bf5cd68
Merge pull request #87 from MystenLabs/enhance/eng-1421-analyze-harde…
harrymove-ctrl Apr 14, 2026
045d804
fix(security): MEM-17 cap recall limit and bound restore concurrency …
ducnmm Apr 13, 2026
bef9bc5
Merge pull request #96 from MystenLabs/feature/mem-17-sec-harden-conc…
harrymove-ctrl Apr 14, 2026
7bf45bd
feat(sec): fix MEM-7 by replacing private_key with key_index over the…
ducnmm Apr 13, 2026
0069c1c
Merge pull request #100 from MystenLabs/feature/mem-7-sec-server-wall…
harrymove-ctrl Apr 14, 2026
058fc39
fix(security): MEM-16 replay protection and block deactivated account…
ducnmm Apr 13, 2026
b49e6e1
Merge pull request #95 from MystenLabs/feature/mem-16-sec-replay-prot…
harrymove-ctrl Apr 14, 2026
62bfff3
fix(security): MEM-18 rate limiter fail-closed on Redis error (MED-19…
ducnmm Apr 13, 2026
a628387
fix(sec): use pg_advisory_xact_lock in transaction for quota check
ducnmm Apr 14, 2026
58ff0cf
Merge pull request #97 from MystenLabs/feature/mem-18-sec-rate-limiti…
harrymove-ctrl Apr 14, 2026
857835e
Merge pull request #90 from MystenLabs/sec/eng-1422-seal-verify-key-s…
harrymove-ctrl Apr 14, 2026
6105b1b
fix(sec): MEM-HIGH-4 phase 01+02 — rate limiting, input
hien-p Apr 14, 2026
3136235
fix: restore HTTP client timeout and background cache eviction
ducnmm Apr 14, 2026
51d106e
Merge pull request #104 from MystenLabs/sec/sponsor-rate-limiting
ducnmm Apr 14, 2026
9245060
fix(security): HIGH-11 remove user_exists status to prevent account e…
hien-p Apr 15, 2026
3546ace
Merge pull request #110 from MystenLabs/feature/mem-20-sec-user-enume…
ducnmm Apr 15, 2026
4edb47f
fix(sec): HIGH-10 — add ownership checks to destructive chat server a…
hien-p Apr 15, 2026
42bc2ce
fix(sec): HIGH-9 — sanitize upload filename and scope blob keys per user
hien-p Apr 15, 2026
069120b
Merge pull request #111 from MystenLabs/feature/mem-21-sec-idor-destr…
ducnmm Apr 15, 2026
e382098
Merge pull request #112 from MystenLabs/feature/mem-22-sec-file-uploa…
ducnmm Apr 15, 2026
14686be
fix(sec): Phase 3 P2 — resolve 20 low-severity & info audit findings
hien-p Apr 15, 2026
9cf73f1
fix(HIGH-2): rate limiter fail-closed with in-memory token-bucket fal…
ducnmm Apr 15, 2026
541bd25
fix: apply low-severity security fixes across server, SDK, and apps
ducnmm Apr 16, 2026
cce0452
Merge pull request #114 from MystenLabs/feature/mem-24-sec-low-severi…
ducnmm Apr 16, 2026
ecbf9d4
docs: add SEAL_THRESHOLD and SEAL_DECRYPT_THRESHOLD to .env.example
ducnmm Apr 16, 2026
f4b057e
chore: merge dev into sec/security_fix
ducnmm Apr 16, 2026
5c6ef9c
fix: add ConnectInfo and x-delegate-key to CORS headers
ducnmm Apr 16, 2026
de7fb02
fix: remove unused import time and skipped variable
ducnmm Apr 16, 2026
8745795
fix(seal): add Studio Mirai mainnet key server, dynamic threshold, se…
ducnmm Apr 16, 2026
20f1745
fix(contract): HIGH-14 version gating + LOW-19/20/21 idempotency & va…
ducnmm Apr 22, 2026
6cf9a8e
fix(sec): remove x-delegate-key from wire (ENG-1696 + ENG-1697)
hien-p Apr 22, 2026
0c71d12
Merge pull request #118 from MystenLabs/sec/seal-session-migration
ducnmm Apr 22, 2026
ff372ee
fix(sec): redact AppError::Internal messages from HTTP response
ducnmm Apr 22, 2026
32d0bcb
fix(sec): add prompt injection defense to fact extraction system prompt
ducnmm Apr 22, 2026
f2a94e4
fix(sec): apply ENG-1697 seal-session and LOW-24 namespace-scoped enc…
ducnmm Apr 29, 2026
ed1010c
fix researcher docker
ducnmm Apr 29, 2026
55ce890
Fix noter save
ducnmm Apr 29, 2026
8a0c5fe
Merge pull request #115 from MystenLabs/sec/security_fix
ducnmm Apr 29, 2026
ea775ee
fix: remove broken npm upgrade step for OIDC trusted publishing
ducnmm Apr 29, 2026
f85bbba
Handle legacy SDK and indexer RPC errors
ducnmm Apr 30, 2026
f6fc1e1
Merge pull request #126 from MystenLabs/sec/security_fix
ducnmm Apr 30, 2026
9e32417
Fix SDK trusted publishing workflow
ducnmm Apr 30, 2026
69c8c0a
Map security branch to SDK dev release
ducnmm Apr 30, 2026
141da24
Revert "Map security branch to SDK dev release"
ducnmm Apr 30, 2026
bcd884e
Merge pull request #127 from MystenLabs/sec/security_fix
ducnmm Apr 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/deploy-app-walrus.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ jobs:
VITE_MEMWAL_PACKAGE_ID: ${{ vars.VITE_MEMWAL_PACKAGE_ID }}
VITE_MEMWAL_REGISTRY_ID: ${{ vars.VITE_MEMWAL_REGISTRY_ID }}
VITE_SEAL_KEY_SERVERS: ${{ vars.VITE_SEAL_KEY_SERVERS }}
VITE_ENOKI_API_KEY: ${{ vars.VITE_ENOKI_API_KEY }}
VITE_GOOGLE_CLIENT_ID: ${{ vars.VITE_GOOGLE_CLIENT_ID }}
VITE_DOCS_URL: ${{ vars.VITE_DOCS_URL }}
VITE_DEMO_URLS: ${{ vars.VITE_DEMO_URLS }}

Expand Down
5 changes: 1 addition & 4 deletions .github/workflows/release-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,10 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '24'
cache: pnpm
registry-url: 'https://registry.npmjs.org'

- name: Upgrade npm for OIDC Trusted Publishing
run: npm install -g npm@latest

- name: Install dependencies
run: pnpm install --frozen-lockfile

Expand Down
81 changes: 75 additions & 6 deletions apps/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
* Flow: Landing → Sign in with Google (Enoki) → Setup Wizard → Dashboard
*/

import { useEffect, useState, useCallback, createContext, useContext } from 'react'
import { useEffect, useState, useCallback, useRef, createContext, useContext } from 'react'
import {
createNetworkConfig,
SuiClientProvider,
WalletProvider,
useCurrentAccount,
useDisconnectWallet,
useSuiClientContext,
} from '@mysten/dapp-kit'
import { isEnokiNetwork, registerEnokiWallets } from '@mysten/enoki'
Expand Down Expand Up @@ -39,7 +40,7 @@ const { networkConfig } = createNetworkConfig({
const queryClient = new QueryClient()

// ============================================================
// Delegate Key Context (stored in localStorage)
// Delegate Key Context (stored in sessionStorage — cleared on tab close, never persists across sessions)
// ============================================================

interface DelegateKeyState {
Expand All @@ -58,6 +59,12 @@ interface DelegateKeyContextType extends DelegateKeyState {

const DelegateKeyContext = createContext<DelegateKeyContextType | null>(null)

// LOW-32: tunable idle-timeout. 15 minutes by default. Exported so callers/tests can read it.
export const INACTIVITY_TIMEOUT_MS = 15 * 60 * 1000

// Debounce interval for activity events to avoid excessive timer resets.
const ACTIVITY_DEBOUNCE_MS = 1000

// eslint-disable-next-line react-refresh/only-export-components
export function useDelegateKey() {
const ctx = useContext(DelegateKeyContext)
Expand All @@ -67,7 +74,7 @@ export function useDelegateKey() {

function DelegateKeyProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<DelegateKeyState>(() => {
const saved = localStorage.getItem('memwal_delegate')
const saved = sessionStorage.getItem('memwal_delegate')
if (saved) {
try { return JSON.parse(saved) } catch { /* ignore */ }
}
Expand All @@ -76,15 +83,77 @@ function DelegateKeyProvider({ children }: { children: React.ReactNode }) {

const setDelegateKeys = useCallback((privateKey: string, publicKey: string, accountId: string) => {
const next = { delegateKey: privateKey, delegatePublicKey: publicKey, accountObjectId: accountId }
localStorage.setItem('memwal_delegate', JSON.stringify(next))
sessionStorage.setItem('memwal_delegate', JSON.stringify(next))
setState(next)
}, [])

const clearDelegateKeys = useCallback(() => {
localStorage.removeItem('memwal_delegate')
setState({ delegateKey: null, delegatePublicKey: null, accountObjectId: null })
// Best-effort zeroization: overwrite the private-key string reference before nulling.
// JS strings are immutable so true wipe is impossible, but we at least drop the last
// live reference held by this provider.
setState((prev) => {
if (prev.delegateKey) {
// Reassign to a placeholder of same length to encourage GC of the original buffer.
// (best-effort — V8 may still retain the interned string)
void prev.delegateKey.replace(/./g, '\0')
}
return { delegateKey: null, delegatePublicKey: null, accountObjectId: null }
})
sessionStorage.removeItem('memwal_delegate')
}, [])

// ============================================================
// LOW-32: Idle-timeout — wipe in-memory key material and disconnect after inactivity.
// ============================================================
const { mutateAsync: disconnect } = useDisconnectWallet()
const hasKey = state.delegateKey !== null
const timerRef = useRef<number | null>(null)
const lastResetRef = useRef<number>(0)

useEffect(() => {
if (!hasKey) return

const triggerWipe = () => {
clearDelegateKeys()
// Fire-and-forget disconnect; redirect to landing regardless.
Promise.resolve(disconnect()).catch(() => { /* ignore */ })
try {
if (window.location.pathname !== '/') {
window.location.assign('/')
}
} catch { /* ignore */ }
}

const scheduleTimer = () => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current)
}
timerRef.current = window.setTimeout(triggerWipe, INACTIVITY_TIMEOUT_MS)
}

const onActivity = () => {
const now = Date.now()
if (now - lastResetRef.current < ACTIVITY_DEBOUNCE_MS) return
lastResetRef.current = now
scheduleTimer()
}

// Start timer on mount.
scheduleTimer()

const events: Array<keyof WindowEventMap> = ['mousemove', 'keydown', 'click', 'scroll', 'touchstart']
const opts: AddEventListenerOptions = { passive: true }
events.forEach((ev) => window.addEventListener(ev, onActivity, opts))

return () => {
events.forEach((ev) => window.removeEventListener(ev, onActivity, opts))
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current)
timerRef.current = null
}
}
}, [hasKey, clearDelegateKeys, disconnect])

return (
<DelegateKeyContext.Provider value={{ ...state, setDelegateKeys, clearDelegateKeys }}>
{children}
Expand Down
42 changes: 36 additions & 6 deletions apps/app/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,29 @@ export default function Dashboard() {
// Generate + add a new delegate key (via SDK)
// ============================================================

// LOW-31: sanitize a key label — strip HTML special chars and control characters
const sanitizeLabel = (raw: string): string =>
raw
// Strip HTML special characters
.replace(/[<>&"'/]/g, '')
// Strip C0/C1 control characters (U+0000–U+001F, U+007F–U+009F)
.replace(/[\x00-\x1F\x7F-\x9F]/g, '')
.trim()

const handleAddKey = useCallback(async () => {
if (!walletSigner) return

// LOW-31: validate label before submitting on-chain
const trimmedLabel = sanitizeLabel(newKeyLabel)
if (!trimmedLabel) {
setKeyError('key label cannot be empty')
return
}
if (trimmedLabel.length > 64) {
setKeyError('key label must be 64 characters or fewer')
return
}

setAddingKey(true)
setKeyError('')
setNewPrivateKey(null)
Expand All @@ -143,7 +164,7 @@ export default function Dashboard() {
packageId: config.memwalPackageId,
accountId: accountObjectId!,
publicKey: delegate.publicKey,
label: newKeyLabel || 'New Key',
label: trimmedLabel,
walletSigner,
suiClient,
suiNetwork: config.suiNetwork,
Expand Down Expand Up @@ -208,11 +229,16 @@ export default function Dashboard() {
// SDK code snippets
// ============================================================

// LOW-30: Never render any portion (prefix/suffix) of the real private key
// in DOM / copyable snippets. Use a static placeholder instead.
const PRIVATE_KEY_PLACEHOLDER = '<YOUR_PRIVATE_KEY>'
const ACCOUNT_ID_PLACEHOLDER = '<YOUR_ACCOUNT_ID>'

const sdkSnippet = `import { MemWal } from "@mysten-incubation/memwal"

const memwal = MemWal.create({
key: "${delegateKey?.slice(0, 8)}...${delegateKey?.slice(-8)}",
accountId: "${accountObjectId?.slice(0, 10)}...",
key: "${PRIVATE_KEY_PLACEHOLDER}",
accountId: "${accountObjectId ?? ACCOUNT_ID_PLACEHOLDER}",
serverUrl: "${config.memwalServerUrl}",
})

Expand All @@ -228,8 +254,8 @@ import { withMemWal } from "@mysten-incubation/memwal/ai"
import { openai } from "@ai-sdk/openai"

const model = withMemWal(openai("gpt-4o"), {
key: "${delegateKey?.slice(0, 8)}...${delegateKey?.slice(-8)}",
accountId: "${accountObjectId?.slice(0, 10)}...",
key: "${PRIVATE_KEY_PLACEHOLDER}",
accountId: "${accountObjectId ?? ACCOUNT_ID_PLACEHOLDER}",
serverUrl: "${config.memwalServerUrl}",
})

Expand Down Expand Up @@ -457,7 +483,11 @@ const result = await generateText({
<input
type="text"
value={newKeyLabel}
onChange={(e) => setNewKeyLabel(e.target.value)}
maxLength={64}
onChange={(e) =>
// LOW-31: strip HTML special chars and control characters on every keystroke
setNewKeyLabel(sanitizeLabel(e.target.value))
}
placeholder="e.g. MacBook Pro, Production Server"
style={{
width: '100%',
Expand Down
5 changes: 3 additions & 2 deletions apps/chatbot/app/(auth)/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ export type RegisterActionState = {
| "in_progress"
| "success"
| "failed"
| "user_exists"
| "invalid_data";
};

Expand All @@ -63,8 +62,10 @@ export const register = async (

const [user] = await getUser(validatedData.email);

// HIGH-11: Return generic failure — do not reveal whether the email is registered.
// Distinct "user_exists" status enables account enumeration.
if (user) {
return { status: "user_exists" } as RegisterActionState;
return { status: "failed" };
}
await createUser(validatedData.email, validatedData.password);
await signIn("credentials", {
Expand Down
30 changes: 29 additions & 1 deletion apps/chatbot/app/(auth)/api/auth/guest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,37 @@ import { getToken } from "next-auth/jwt";
import { signIn } from "@/app/(auth)/auth";
import { isDevelopmentEnvironment } from "@/lib/constants";

/**
* Validate a redirect target before forwarding to auth.
* Allows only:
* - Relative paths beginning with "/" (but not "//", which is protocol-relative)
* - Absolute URLs whose origin matches the request origin (same-origin)
* Anything else (external hosts, javascript:, data:, //evil.com) falls back to "/".
*/
function isSafeRedirectUrl(redirectUrl: string, requestUrl: string): boolean {
// Relative path — safe as long as it isn't protocol-relative ("//host/...")
if (redirectUrl.startsWith("/") && !redirectUrl.startsWith("//")) {
return true;
}
// Absolute URL — must share the same origin as the request
try {
const redirectOrigin = new URL(redirectUrl).origin;
const requestOrigin = new URL(requestUrl).origin;
return redirectOrigin === requestOrigin;
} catch {
// Unparseable URL (e.g. "javascript:alert(1)") — reject
return false;
}
}

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const redirectUrl = searchParams.get("redirectUrl") || "/";
const rawRedirectUrl = searchParams.get("redirectUrl") || "/";

// Reject cross-origin or protocol-relative redirect targets
const redirectUrl = isSafeRedirectUrl(rawRedirectUrl, request.url)
? rawRedirectUrl
: "/";

const token = await getToken({
req: request,
Expand Down
4 changes: 1 addition & 3 deletions apps/chatbot/app/(auth)/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@ export default function Page() {

// biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs
useEffect(() => {
if (state.status === "user_exists") {
toast({ type: "error", description: "Account already exists!" });
} else if (state.status === "failed") {
if (state.status === "failed") {
toast({ type: "error", description: "Failed to create account!" });
} else if (state.status === "invalid_data") {
toast({
Expand Down
36 changes: 35 additions & 1 deletion apps/chatbot/app/(chat)/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,23 @@ import { titlePrompt } from "@/lib/ai/prompts";
import { getTitleModel } from "@/lib/ai/providers";
import {
deleteMessagesByChatIdAfterTimestamp,
getChatById,
getMessageById,
updateChatVisibilityById,
} from "@/lib/db/queries";
import { getTextFromMessage } from "@/lib/utils";
import { auth } from "@/app/(auth)/auth";
import { isProductionEnvironment } from "@/lib/constants";

export async function saveChatModelAsCookie(model: string) {
const cookieStore = await cookies();
cookieStore.set("chat-model", model);
// LOW-33: Set Secure (in prod), SameSite=Lax, and constrain path so the
// cookie is not sent cross-site and is protected in transit.
cookieStore.set("chat-model", model, {
path: "/",
sameSite: "lax",
secure: isProductionEnvironment,
});
}

export async function generateTitleFromUserMessage({
Expand All @@ -34,7 +43,21 @@ export async function generateTitleFromUserMessage({
}

export async function deleteTrailingMessages({ id }: { id: string }) {
// HIGH-10: Verify the requesting user owns the chat before deleting messages.
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthorized");
}

const [message] = await getMessageById({ id });
if (!message) {
throw new Error("Message not found");
}

const chat = await getChatById({ id: message.chatId });
if (!chat || chat.userId !== session.user.id) {
throw new Error("Unauthorized");
}

await deleteMessagesByChatIdAfterTimestamp({
chatId: message.chatId,
Expand All @@ -49,5 +72,16 @@ export async function updateChatVisibility({
chatId: string;
visibility: VisibilityType;
}) {
// HIGH-10: Verify the requesting user owns the chat before changing visibility.
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthorized");
}

const chat = await getChatById({ id: chatId });
if (!chat || chat.userId !== session.user.id) {
throw new Error("Unauthorized");
}

await updateChatVisibilityById({ chatId, visibility });
}
Loading
Loading