diff --git a/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx b/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx
index 0bb7c317907..18b9b71a49d 100644
--- a/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx
+++ b/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx
@@ -56,7 +56,28 @@ describe("ExperimentalFileLink", () => {
expect(openFilePreview).not.toHaveBeenCalled();
});
- it("does not dispatch a malformed target supplied across a JavaScript boundary", () => {
+ it("uses a scheme-safe href for a valid scheme-like file name", () => {
+ const openFilePreview = vi.fn(() => true);
+ render(
+
+
+
+ vscode:foo
+
+
+ ,
+ );
+ const link = screen.getByRole("link", { name: "vscode:foo" });
+ expect(link.getAttribute("href")).toBe("./vscode%3Afoo");
+
+ fireEvent.click(link);
+ expect(openFilePreview).toHaveBeenCalledWith({
+ target: { ...target, path: "vscode:foo" },
+ location: null,
+ });
+ });
+
+ it("renders a malformed target supplied across a JavaScript boundary as inert", () => {
const openFilePreview = vi.fn(() => true);
render(
@@ -67,7 +88,30 @@ describe("ExperimentalFileLink", () => {
,
);
- fireEvent.click(screen.getByRole("link", { name: "invalid" }));
+ const invalid = screen.getByText("invalid");
+ expect(screen.queryByRole("link", { name: "invalid" })).toBeNull();
+ expect(invalid.getAttribute("href")).toBeNull();
+ fireEvent.click(invalid);
+ expect(openFilePreview).not.toHaveBeenCalled();
+ });
+
+ it("renders a path with an unpaired UTF-16 surrogate as inert", () => {
+ const openFilePreview = vi.fn(() => true);
+ render(
+
+
+
+ invalid Unicode
+
+
+ ,
+ );
+ const invalid = screen.getByText("invalid Unicode");
+ expect(screen.queryByRole("link", { name: "invalid Unicode" })).toBeNull();
+ expect(invalid.getAttribute("href")).toBeNull();
+ fireEvent.click(invalid);
expect(openFilePreview).not.toHaveBeenCalled();
});
});
diff --git a/apps/app/src/components/plugin/ExperimentalFileLink.tsx b/apps/app/src/components/plugin/ExperimentalFileLink.tsx
index 24be2d40d03..e01f56aab15 100644
--- a/apps/app/src/components/plugin/ExperimentalFileLink.tsx
+++ b/apps/app/src/components/plugin/ExperimentalFileLink.tsx
@@ -61,8 +61,10 @@ export function ExperimentalFileLink({
},
[intent, navigation, onClick],
);
+ const href =
+ intent === null ? undefined : `./${encodeURIComponent(intent.target.path)}`;
const anchor = (
-
+
);
if (intent === null) return anchor;
diff --git a/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx b/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx
index d97966ca335..5b530a7d214 100644
--- a/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx
+++ b/apps/app/src/components/plugin/ExperimentalUrlLink.test.tsx
@@ -44,6 +44,74 @@ describe("ExperimentalUrlLink", () => {
expect(openUrl).not.toHaveBeenCalled();
});
+ it.each(["_blank", "preview-pane"])(
+ "leaves the explicit %s target native when the URL host would accept it",
+ (target) => {
+ const openUrl = vi.fn(() => true);
+ render(
+
+
+
+ Example
+
+
+ ,
+ );
+ const link = screen.getByRole("link", { name: "Example" });
+ expect(link.getAttribute("target")).toBe(target);
+ expect(link.getAttribute("rel")).toBe("noopener noreferrer");
+ expect(fireEvent.click(link)).toBe(true);
+ expect(openUrl).not.toHaveBeenCalled();
+ },
+ );
+
+ it("preserves an explicit rel for a named target", () => {
+ render(
+
+
+ Example
+
+ ,
+ );
+ expect(
+ screen.getByRole("link", { name: "Example" }).getAttribute("rel"),
+ ).toBe("opener");
+ });
+
+ it("preserves rel tokens without sacrificing named-target isolation", () => {
+ render(
+
+
+ Example
+
+ ,
+ );
+ expect(
+ screen.getByRole("link", { name: "Example" }).getAttribute("rel"),
+ ).toBe("nofollow noopener noreferrer");
+ });
+
+ it("does not add new-context rel tokens to a same-context target", () => {
+ render(
+
+
+ Example
+
+ ,
+ );
+ expect(
+ screen.getByRole("link", { name: "Example" }).getAttribute("rel"),
+ ).toBeNull();
+ });
+
it("routes internal links through browser history before URL preferences", () => {
const openUrl = vi.fn(() => true);
render(
@@ -62,4 +130,29 @@ describe("ExperimentalUrlLink", () => {
expect(screen.getByText("Settings route")).toBeTruthy();
expect(openUrl).not.toHaveBeenCalled();
});
+
+ it("keeps an explicit target on an internal route native", () => {
+ const openUrl = vi.fn(() => true);
+ render(
+
+
+
+
+ Settings in new context
+
+
+ Settings route} />
+
+
+
+ ,
+ );
+ const link = screen.getByRole("link", {
+ name: "Settings in new context",
+ });
+ expect(link.getAttribute("target")).toBe("_blank");
+ expect(fireEvent.click(link)).toBe(true);
+ expect(screen.queryByText("Settings route")).toBeNull();
+ expect(openUrl).not.toHaveBeenCalled();
+ });
});
diff --git a/apps/app/src/components/plugin/ExperimentalUrlLink.tsx b/apps/app/src/components/plugin/ExperimentalUrlLink.tsx
index 3ac1984d24d..a2564f59376 100644
--- a/apps/app/src/components/plugin/ExperimentalUrlLink.tsx
+++ b/apps/app/src/components/plugin/ExperimentalUrlLink.tsx
@@ -14,7 +14,8 @@ function shouldHandleUrlClick(
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
- event.currentTarget.hasAttribute("download")
+ event.currentTarget.hasAttribute("download") ||
+ event.currentTarget.hasAttribute("target")
) {
return false;
}
@@ -51,12 +52,40 @@ export function ExperimentalUrlLink({
},
[href, navigation, onClick],
);
+ const normalizedTarget = target?.toLowerCase();
+ const opensNewBrowsingContext =
+ normalizedTarget !== undefined &&
+ normalizedTarget !== "" &&
+ normalizedTarget !== "_self" &&
+ normalizedTarget !== "_parent" &&
+ normalizedTarget !== "_top" &&
+ normalizedTarget !== "_unfencedtop";
+ const relTokens = rel?.split(/\s+/u).filter(Boolean) ?? [];
+ const normalizedRelTokens = relTokens.map((token) => token.toLowerCase());
+ const resolvedRel =
+ opensNewBrowsingContext && !normalizedRelTokens.includes("opener")
+ ? [
+ ...relTokens,
+ ...(normalizedRelTokens.includes("noopener") ? [] : ["noopener"]),
+ ...(normalizedRelTokens.includes("noreferrer") ? [] : ["noreferrer"]),
+ ].join(" ")
+ : rel;
+ if (target !== undefined) {
+ return (
+
+ );
+ }
return (
);
diff --git a/apps/app/src/lib/live-file-navigation.test.ts b/apps/app/src/lib/live-file-navigation.test.ts
index be915d1ad8f..49885227472 100644
--- a/apps/app/src/lib/live-file-navigation.test.ts
+++ b/apps/app/src/lib/live-file-navigation.test.ts
@@ -56,6 +56,30 @@ describe("normalizeExperimentalLiveFileTarget", () => {
])("rejects ambiguous or non-exact target %#", (target) => {
expect(normalizeExperimentalLiveFileTarget(target)).toBeNull();
});
+
+ it("accepts valid surrogate pairs and rejects unpaired surrogates", () => {
+ expect(
+ normalizeExperimentalLiveFileTarget({
+ kind: "workspace",
+ environmentId: "env_1",
+ path: `reports/${String.fromCodePoint(0x1f4c4)}.md`,
+ }),
+ ).not.toBeNull();
+
+ for (const path of [
+ String.fromCharCode(0xd800),
+ String.fromCharCode(0xdc00),
+ `a${String.fromCharCode(0xd800)}b`,
+ ]) {
+ expect(
+ normalizeExperimentalLiveFileTarget({
+ kind: "workspace",
+ environmentId: "env_1",
+ path,
+ }),
+ ).toBeNull();
+ }
+ });
});
describe("normalizeExperimentalFileOpenOptions", () => {
diff --git a/apps/app/src/lib/live-file-navigation.ts b/apps/app/src/lib/live-file-navigation.ts
index 4fe4a19709e..dc4c557af65 100644
--- a/apps/app/src/lib/live-file-navigation.ts
+++ b/apps/app/src/lib/live-file-navigation.ts
@@ -1,194 +1,11 @@
-import type {
- ExperimentalFileLocation,
- ExperimentalFileOpenOptions,
- ExperimentalLiveFileTarget,
-} from "@get-bb/plugin-sdk";
+import type { ExperimentalFileLocation } from "@get-bb/plugin-sdk";
+export {
+ normalizeExperimentalFileLocation,
+ normalizeExperimentalFileOpenOptions,
+ normalizeExperimentalLiveFileTarget,
+} from "@get-bb/plugin-sdk/internal/file-navigation-validation";
import type { FilePreviewLineRange } from "@/lib/file-preview";
-const FILE_PATH_MAX_LENGTH = 32_768;
-const WINDOWS_DRIVE_ABSOLUTE_PATH = /^[A-Za-z]:[\\/]/u;
-const WINDOWS_UNC_ABSOLUTE_PATH = /^\\\\/u;
-
-function isJsonObject(value: unknown): value is Record {
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
- return false;
- }
- const prototype = Object.getPrototypeOf(value);
- return prototype === Object.prototype || prototype === null;
-}
-
-function hasExactKeys(
- value: Record,
- keys: readonly string[],
-): boolean {
- const actualKeys = Object.keys(value);
- return (
- actualKeys.length === keys.length &&
- keys.every((key) => Object.prototype.hasOwnProperty.call(value, key))
- );
-}
-
-function isNonEmptyIdentity(value: unknown): value is string {
- return (
- typeof value === "string" &&
- value.length > 0 &&
- value.length <= FILE_PATH_MAX_LENGTH &&
- value.trim() === value
- );
-}
-
-function hasControlCharacter(value: string): boolean {
- for (const character of value) {
- const codePoint = character.codePointAt(0);
- if (codePoint !== undefined && codePoint < 0x20) return true;
- }
- return false;
-}
-
-function isValidPathSegment(segment: string): boolean {
- return segment.length > 0 && segment !== "." && segment !== "..";
-}
-
-function isPositiveSafeInteger(value: unknown): value is number {
- return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
-}
-
-function isValidRelativeFilePath(value: unknown): value is string {
- if (
- typeof value !== "string" ||
- value.length === 0 ||
- value.length > FILE_PATH_MAX_LENGTH ||
- value.trim() !== value ||
- value.includes("\\") ||
- hasControlCharacter(value)
- ) {
- return false;
- }
- return value.split("/").every(isValidPathSegment);
-}
-
-function isValidAbsoluteHostFilePath(value: unknown): value is string {
- if (
- typeof value !== "string" ||
- value.length === 0 ||
- value.length > FILE_PATH_MAX_LENGTH ||
- value.trim() !== value ||
- hasControlCharacter(value)
- ) {
- return false;
- }
-
- if (value.startsWith("/") && !value.startsWith("//")) {
- const segments = value.slice(1).split("/");
- return segments.length > 0 && segments.every(isValidPathSegment);
- }
-
- if (WINDOWS_DRIVE_ABSOLUTE_PATH.test(value)) {
- const segments = value.slice(3).split(/[\\/]/u);
- return segments.length > 0 && segments.every(isValidPathSegment);
- }
-
- if (WINDOWS_UNC_ABSOLUTE_PATH.test(value)) {
- const segments = value.slice(2).split(/[\\/]/u);
- return segments.length >= 3 && segments.every(isValidPathSegment);
- }
-
- return false;
-}
-
-export function normalizeExperimentalLiveFileTarget(
- value: unknown,
-): ExperimentalLiveFileTarget | null {
- if (!isJsonObject(value) || typeof value.kind !== "string") return null;
-
- switch (value.kind) {
- case "workspace":
- if (
- !hasExactKeys(value, ["kind", "environmentId", "path"]) ||
- !isNonEmptyIdentity(value.environmentId) ||
- !isValidRelativeFilePath(value.path)
- ) {
- return null;
- }
- return {
- kind: value.kind,
- environmentId: value.environmentId,
- path: value.path,
- };
- case "host":
- if (
- !hasExactKeys(value, ["kind", "hostId", "path"]) ||
- !isNonEmptyIdentity(value.hostId) ||
- !isValidAbsoluteHostFilePath(value.path)
- ) {
- return null;
- }
- return { kind: value.kind, hostId: value.hostId, path: value.path };
- case "thread-storage":
- if (
- !hasExactKeys(value, ["kind", "threadId", "path"]) ||
- !isNonEmptyIdentity(value.threadId) ||
- !isValidRelativeFilePath(value.path)
- ) {
- return null;
- }
- return { kind: value.kind, threadId: value.threadId, path: value.path };
- default:
- return null;
- }
-}
-
-export function normalizeExperimentalFileLocation(
- value: unknown,
-): ExperimentalFileLocation | null | undefined {
- if (value === null) return null;
- if (!isJsonObject(value) || typeof value.kind !== "string") return undefined;
-
- switch (value.kind) {
- case "line":
- if (
- !hasExactKeys(value, ["kind", "line", "column"]) ||
- !isPositiveSafeInteger(value.line) ||
- (value.column !== null && !isPositiveSafeInteger(value.column))
- ) {
- return undefined;
- }
- return {
- kind: value.kind,
- line: value.line,
- column: value.column,
- };
- case "range":
- if (
- !hasExactKeys(value, ["kind", "startLine", "endLine"]) ||
- !isPositiveSafeInteger(value.startLine) ||
- !isPositiveSafeInteger(value.endLine) ||
- value.endLine < value.startLine
- ) {
- return undefined;
- }
- return {
- kind: value.kind,
- startLine: value.startLine,
- endLine: value.endLine,
- };
- default:
- return undefined;
- }
-}
-
-export function normalizeExperimentalFileOpenOptions(
- value: unknown,
-): ExperimentalFileOpenOptions | null {
- if (!isJsonObject(value) || !hasExactKeys(value, ["target", "location"])) {
- return null;
- }
- const target = normalizeExperimentalLiveFileTarget(value.target);
- const location = normalizeExperimentalFileLocation(value.location);
- if (target === null || location === undefined) return null;
- return { target, location };
-}
-
export function getExperimentalFileLocationStart(
location: ExperimentalFileLocation | null,
): { columnNumber: number | null; lineNumber: number | null } {
diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md
index 355ff449667..aa243077d02 100644
--- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md
+++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md
@@ -1889,8 +1889,10 @@ className?, leadingContent?, messageActions? }` —
- `experimental_UrlLink` — a real anchor whose ordinary HTTP(S) activation
follows the current client's in-app/external-browser preference. It keeps
internal BB routes in SPA history, preserves modifier clicks, copying,
- accessibility, and explicit anchor props, and leaves unsupported schemes to
- normal browser behavior. Use `useBbNavigate().experimental_openUrl(url)` for
+ accessibility, and explicit anchor props, and leaves unsupported schemes and
+ explicit targets to browser behavior. A `_blank` or named target preserves
+ supplied `rel` tokens but adds `noopener noreferrer` unless `rel` explicitly
+ contains `opener`. Use `useBbNavigate().experimental_openUrl(url)` for
buttons, menus, and effects; its boolean reports whether the current app
accepted the intent, not whether a later OS launch completed.
- `experimental_FileLink` — a real anchor for an explicit live file target:
@@ -1899,8 +1901,11 @@ className?, leadingContent?, messageActions? }` —
`{ kind: "thread-storage", threadId, path }`. Ordinary activation opens the
current surface's shared BB preview. Its lazy context menu offers the
built-in preview, matching plugin `fileOpener`s, the preferred external
- target, available client apps, and copy actions. Optional `location` is a
- one-based line/column or line range. For buttons and effects use
+ target, available client apps, and copy actions. Valid targets expose an
+ encoded, scheme-safe href; traversal paths, ill-formed Unicode, and other
+ malformed runtime targets are inert in both the app and SDK test harness.
+ Optional `location` is a one-based line/column or line range. For buttons and
+ effects use
`useBbNavigate().experimental_openFilePreview({ target, location })` or
`.experimental_openFileExternally({ target, location })`; the boolean means
host acceptance, not completed I/O. Every identity is explicit—never invent
diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md
index cc1480c578b..1f80e2a219d 100644
--- a/docs/api_to_audit.md
+++ b/docs/api_to_audit.md
@@ -54,10 +54,12 @@ details to clients.
**What it does.** Gives plugin UI the same semantic HTTP(S) opening path as
first-party UI. Ordinary activation respects the current client's in-app
browser preference and capability; app routes remain SPA navigation, modifier
-clicks and explicit anchor targets remain native, and unsupported schemes are
-left to the browser. The imperative method returns whether the current app
-accepted the intent. The frontend harness records link and imperative calls
-through the same navigation inspection log.
+clicks and explicit anchor targets remain browser-owned, and unsupported
+schemes are left to the browser. New top-level targets preserve supplied `rel`
+tokens but add `noopener noreferrer` unless `rel` explicitly contains
+`opener`. The imperative method returns whether the current app accepted the
+intent. The frontend harness records link and imperative calls through the same
+navigation inspection log.
**Audit before stabilizing.**
@@ -67,7 +69,8 @@ through the same navigation inspection log.
keyboard activation, explicit targets, copied hrefs, and accessible names.
3. Confirm the component should retain ordinary anchor props rather than a
smaller styled-link contract, and that explicit `target` continues to mean
- native browser behavior rather than BB preference routing.
+ browser behavior rather than BB preference routing while safe default `rel`
+ values prevent implicit opener access.
4. Measure use across plugin pages, Settings sections, panel tabs, Markdown,
and menus before stabilizing the boolean acceptance contract.
5. Keep the host implementation in the shell and verify plugin bundles contain
@@ -83,7 +86,9 @@ external method resolves the current client's preferred file target, absolute
path, local/remote-SSH context, and line/column support. The boolean methods
report host acceptance; later OS failures remain host-owned. The host id added
to file-opener sources preserves explicit host identity when a plugin page
-opens a host file without ambient thread context.
+opens a host file without ambient thread context. Valid link targets expose a
+scheme-safe href, while traversal paths, ill-formed Unicode, and other
+malformed runtime targets remain inert in both the app and SDK test runtime.
**Audit before stabilizing.**
diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md
index c133f7adf04..34a06d1218b 100644
--- a/packages/plugin-sdk/README.md
+++ b/packages/plugin-sdk/README.md
@@ -25,13 +25,19 @@ Use `experimental_UrlLink` for a real anchor that applies BB's current
in-app/external-browser preference on ordinary HTTP(S) activation, or
`useBbNavigate().experimental_openUrl(url)` for a button or menu. Internal app
routes, modifier clicks, explicit anchor targets, and unsupported schemes stay
-native. The frontend harness records both forms in `navigateCalls` and accepts
-an `openUrl` behavior option.
+browser-owned. A `_blank` or named target preserves supplied `rel` tokens but
+adds `noopener noreferrer` unless `rel` explicitly contains `opener`, so a
+newly opened page cannot control BB by accident. The frontend harness records
+both forms in `navigateCalls` and accepts an `openUrl` behavior option.
Use `experimental_FileLink` for an explicit live workspace, host, or
thread-storage file. Ordinary activation opens the shared BB preview and its
context menu exposes built-in/plugin viewers, preferred external opening, and
-copy actions. Buttons and menus can call
+copy actions. Valid targets expose an encoded, scheme-safe anchor href so
+modifier clicks, downloads, and copied links cannot reinterpret a file name as
+an external URL scheme. Malformed runtime targets—including traversal paths
+and ill-formed Unicode—have no active href and cannot record a preview in the
+frontend harness. Buttons and menus can call
`experimental_openFilePreview({ target, location })` or
`experimental_openFileExternally({ target, location })`; both return whether
the current host accepted the intent. Targets never infer an ambient workspace.
diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json
index 660ac115842..4499adcf2f7 100644
--- a/packages/plugin-sdk/package.json
+++ b/packages/plugin-sdk/package.json
@@ -56,6 +56,12 @@
"import": "./dist/internal/composer-view.js",
"default": "./dist/internal/composer-view.js"
},
+ "./internal/file-navigation-validation": {
+ "source": "./src/internal/file-navigation-validation.ts",
+ "types": "./bundled-types/bb-plugin-sdk-internal-file-navigation-validation.d.ts",
+ "import": "./dist/internal/file-navigation-validation.js",
+ "default": "./dist/internal/file-navigation-validation.js"
+ },
"./internal/host-policy": {
"source": "./src/internal/host-policy.ts",
"types": "./bundled-types/bb-plugin-sdk-internal-host-policy.d.ts",
diff --git a/packages/plugin-sdk/scripts/build-bundled-dts.mjs b/packages/plugin-sdk/scripts/build-bundled-dts.mjs
index 89fc7f2dbf2..d1362d6db17 100644
--- a/packages/plugin-sdk/scripts/build-bundled-dts.mjs
+++ b/packages/plugin-sdk/scripts/build-bundled-dts.mjs
@@ -53,6 +53,10 @@ const outputs = {
pkgRoot,
"src/internal/composer-view.ts",
),
+ "bb-plugin-sdk-internal-file-navigation-validation.d.ts": path.join(
+ pkgRoot,
+ "src/internal/file-navigation-validation.ts",
+ ),
"bb-plugin-sdk-internal-host-policy.d.ts": path.join(
pkgRoot,
"src/internal/host-policy.ts",
diff --git a/packages/plugin-sdk/scripts/build-runtime.mjs b/packages/plugin-sdk/scripts/build-runtime.mjs
index 9c54d033a6d..a41446255d4 100644
--- a/packages/plugin-sdk/scripts/build-runtime.mjs
+++ b/packages/plugin-sdk/scripts/build-runtime.mjs
@@ -31,6 +31,11 @@ const entries = [
output: "dist/internal/composer-view.js",
external: [],
},
+ {
+ source: "src/internal/file-navigation-validation.ts",
+ output: "dist/internal/file-navigation-validation.js",
+ external: [],
+ },
{
source: "src/internal/host-policy.ts",
output: "dist/internal/host-policy.js",
diff --git a/packages/plugin-sdk/src/__tests__/package-exports.test.ts b/packages/plugin-sdk/src/__tests__/package-exports.test.ts
index e5bde7233cd..ff3b51cd2db 100644
--- a/packages/plugin-sdk/src/__tests__/package-exports.test.ts
+++ b/packages/plugin-sdk/src/__tests__/package-exports.test.ts
@@ -28,6 +28,7 @@ describe("packed plugin SDK exports", () => {
"./host",
"./internal/composer-customization-validation",
"./internal/composer-view",
+ "./internal/file-navigation-validation",
"./internal/host-policy",
"./internal/plugin-app-collector",
"./testing",
diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts
index c148065e5bc..9cd05c86602 100644
--- a/packages/plugin-sdk/src/app-contract.ts
+++ b/packages/plugin-sdk/src/app-contract.ts
@@ -1597,8 +1597,10 @@ export interface MarkdownProps {
/**
* Props for BB's semantic URL link. The host owns ordinary activation while
- * retaining native anchor behavior for app routes, modifiers, copying, and
- * unsupported schemes. Experimental: see docs/api_to_audit.md.
+ * retaining browser-owned anchor behavior for app routes, modifiers, explicit
+ * targets, copying, and unsupported schemes. New top-level targets preserve
+ * supplied `rel` tokens and receive safe defaults unless `opener` is explicit.
+ * Experimental: see docs/api_to_audit.md.
*/
export interface ExperimentalUrlLinkProps extends Omit<
ComponentPropsWithoutRef<"a">,
@@ -1624,7 +1626,11 @@ export interface ExperimentalFileOpenOptions {
location: ExperimentalFileLocation | null;
}
-/** Props for BB's host-rendered semantic file link. */
+/**
+ * Props for BB's host-rendered semantic file link. Valid targets receive a
+ * scheme-safe anchor href; traversal paths, ill-formed Unicode, and other
+ * malformed runtime targets remain inert.
+ */
export interface ExperimentalFileLinkProps extends Omit<
ComponentPropsWithoutRef<"a">,
"href" | "target"
diff --git a/packages/plugin-sdk/src/internal/file-navigation-validation.ts b/packages/plugin-sdk/src/internal/file-navigation-validation.ts
new file mode 100644
index 00000000000..8d0ca87ba89
--- /dev/null
+++ b/packages/plugin-sdk/src/internal/file-navigation-validation.ts
@@ -0,0 +1,206 @@
+import type {
+ ExperimentalFileLocation,
+ ExperimentalFileOpenOptions,
+ ExperimentalLiveFileTarget,
+} from "../app-contract.js";
+
+const FILE_PATH_MAX_LENGTH = 32_768;
+const WINDOWS_DRIVE_ABSOLUTE_PATH = /^[A-Za-z]:[\\/]/u;
+const WINDOWS_UNC_ABSOLUTE_PATH = /^\\\\/u;
+
+function isJsonObject(value: unknown): value is Record {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ return false;
+ }
+ const prototype = Object.getPrototypeOf(value);
+ return prototype === Object.prototype || prototype === null;
+}
+
+function hasExactKeys(
+ value: Record,
+ keys: readonly string[],
+): boolean {
+ const actualKeys = Object.keys(value);
+ return (
+ actualKeys.length === keys.length &&
+ keys.every((key) => Object.prototype.hasOwnProperty.call(value, key))
+ );
+}
+
+function isNonEmptyIdentity(value: unknown): value is string {
+ return (
+ typeof value === "string" &&
+ value.length > 0 &&
+ value.length <= FILE_PATH_MAX_LENGTH &&
+ value.trim() === value
+ );
+}
+
+function hasControlCharacter(value: string): boolean {
+ for (const character of value) {
+ const codePoint = character.codePointAt(0);
+ if (codePoint !== undefined && codePoint < 0x20) return true;
+ }
+ return false;
+}
+
+function hasUnpairedSurrogate(value: string): boolean {
+ for (let index = 0; index < value.length; index += 1) {
+ const codeUnit = value.charCodeAt(index);
+ if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
+ if (index + 1 >= value.length) return true;
+ const nextCodeUnit = value.charCodeAt(index + 1);
+ if (nextCodeUnit < 0xdc00 || nextCodeUnit > 0xdfff) return true;
+ index += 1;
+ } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function isValidPathSegment(segment: string): boolean {
+ return segment.length > 0 && segment !== "." && segment !== "..";
+}
+
+function isPositiveSafeInteger(value: unknown): value is number {
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
+}
+
+function isValidRelativeFilePath(value: unknown): value is string {
+ if (
+ typeof value !== "string" ||
+ value.length === 0 ||
+ value.length > FILE_PATH_MAX_LENGTH ||
+ value.trim() !== value ||
+ value.includes("\\") ||
+ hasControlCharacter(value) ||
+ hasUnpairedSurrogate(value)
+ ) {
+ return false;
+ }
+ return value.split("/").every(isValidPathSegment);
+}
+
+function isValidAbsoluteHostFilePath(value: unknown): value is string {
+ if (
+ typeof value !== "string" ||
+ value.length === 0 ||
+ value.length > FILE_PATH_MAX_LENGTH ||
+ value.trim() !== value ||
+ hasControlCharacter(value) ||
+ hasUnpairedSurrogate(value)
+ ) {
+ return false;
+ }
+
+ if (value.startsWith("/") && !value.startsWith("//")) {
+ const segments = value.slice(1).split("/");
+ return segments.length > 0 && segments.every(isValidPathSegment);
+ }
+
+ if (WINDOWS_DRIVE_ABSOLUTE_PATH.test(value)) {
+ const segments = value.slice(3).split(/[\\/]/u);
+ return segments.length > 0 && segments.every(isValidPathSegment);
+ }
+
+ if (WINDOWS_UNC_ABSOLUTE_PATH.test(value)) {
+ const segments = value.slice(2).split(/[\\/]/u);
+ return segments.length >= 3 && segments.every(isValidPathSegment);
+ }
+
+ return false;
+}
+
+export function normalizeExperimentalLiveFileTarget(
+ value: unknown,
+): ExperimentalLiveFileTarget | null {
+ if (!isJsonObject(value) || typeof value.kind !== "string") return null;
+
+ switch (value.kind) {
+ case "workspace":
+ if (
+ !hasExactKeys(value, ["kind", "environmentId", "path"]) ||
+ !isNonEmptyIdentity(value.environmentId) ||
+ !isValidRelativeFilePath(value.path)
+ ) {
+ return null;
+ }
+ return {
+ kind: value.kind,
+ environmentId: value.environmentId,
+ path: value.path,
+ };
+ case "host":
+ if (
+ !hasExactKeys(value, ["kind", "hostId", "path"]) ||
+ !isNonEmptyIdentity(value.hostId) ||
+ !isValidAbsoluteHostFilePath(value.path)
+ ) {
+ return null;
+ }
+ return { kind: value.kind, hostId: value.hostId, path: value.path };
+ case "thread-storage":
+ if (
+ !hasExactKeys(value, ["kind", "threadId", "path"]) ||
+ !isNonEmptyIdentity(value.threadId) ||
+ !isValidRelativeFilePath(value.path)
+ ) {
+ return null;
+ }
+ return { kind: value.kind, threadId: value.threadId, path: value.path };
+ default:
+ return null;
+ }
+}
+
+export function normalizeExperimentalFileLocation(
+ value: unknown,
+): ExperimentalFileLocation | null | undefined {
+ if (value === null) return null;
+ if (!isJsonObject(value) || typeof value.kind !== "string") return undefined;
+
+ switch (value.kind) {
+ case "line":
+ if (
+ !hasExactKeys(value, ["kind", "line", "column"]) ||
+ !isPositiveSafeInteger(value.line) ||
+ (value.column !== null && !isPositiveSafeInteger(value.column))
+ ) {
+ return undefined;
+ }
+ return {
+ kind: value.kind,
+ line: value.line,
+ column: value.column,
+ };
+ case "range":
+ if (
+ !hasExactKeys(value, ["kind", "startLine", "endLine"]) ||
+ !isPositiveSafeInteger(value.startLine) ||
+ !isPositiveSafeInteger(value.endLine) ||
+ value.endLine < value.startLine
+ ) {
+ return undefined;
+ }
+ return {
+ kind: value.kind,
+ startLine: value.startLine,
+ endLine: value.endLine,
+ };
+ default:
+ return undefined;
+ }
+}
+
+export function normalizeExperimentalFileOpenOptions(
+ value: unknown,
+): ExperimentalFileOpenOptions | null {
+ if (!isJsonObject(value) || !hasExactKeys(value, ["target", "location"])) {
+ return null;
+ }
+ const target = normalizeExperimentalLiveFileTarget(value.target);
+ const location = normalizeExperimentalFileLocation(value.location);
+ if (target === null || location === undefined) return null;
+ return { target, location };
+}
diff --git a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx
index a1c1d8b3d54..f7ebe618fae 100644
--- a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx
+++ b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx
@@ -153,6 +153,13 @@ function UrlNavigationProbe() {
return (
Open link
+
+ Open in explicit target
+