Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
86 changes: 81 additions & 5 deletions packages/api/src/services/terminal-image-fetch-core.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { fileURLToPath } from "node:url"

export type TerminalImageFetchPlan =
| {
readonly _tag: "InvalidTerminalImageFetch"
Expand All @@ -23,6 +25,21 @@ const controlCharRange = `${String.fromCodePoint(0)}-${String.fromCodePoint(0x1F
const deleteChar = String.fromCodePoint(0x7F)
const invalidCharacterPattern = new RegExp(String.raw`[\s${controlCharRange}${deleteChar}]`, "u")
const traversalPattern = /(?:^|\/)(?:\.|\.\.)(?=\/|$)/u
const urlSchemePattern = /^[A-Za-z][A-Za-z0-9+.-]*:/u
const fileUrlPattern = /^file:\/\//iu
const encodedPathSeparatorPattern = /%(?:2f|5c)/iu
const fileUrlBackslashPattern = /\\/u
const fileUrlTraversalPattern = /(?:^|[\\/])(?:\.|%2e)(?:(?:\.|%2e))?(?=[\\/]|$)/iu

type TerminalImagePathNormalization =
| {
readonly _tag: "InvalidTerminalImagePath"
readonly message: string
}
| {
readonly _tag: "ValidTerminalImagePath"
readonly path: string
}

const lowercaseExtension = (path: string): string | null => {
const lastDot = path.lastIndexOf(".")
Expand All @@ -32,26 +49,85 @@ const lowercaseExtension = (path: string): string | null => {
return path.slice(lastDot + 1).toLowerCase()
}

const rawFileUrlPathname = (path: string): string => {
const withoutScheme = path.slice("file://".length)
const pathStart = withoutScheme.indexOf("/")
if (pathStart < 0) {
return ""
}
const pathAndSuffix = withoutScheme.slice(pathStart)
const queryStart = pathAndSuffix.indexOf("?")
const hashStart = pathAndSuffix.indexOf("#")
if (queryStart < 0 && hashStart < 0) {
return pathAndSuffix
}
if (queryStart < 0) {
return pathAndSuffix.slice(0, hashStart)
}
if (hashStart < 0) {
return pathAndSuffix.slice(0, queryStart)
}
return pathAndSuffix.slice(0, Math.min(queryStart, hashStart))
}

const normalizeTerminalImagePath = (path: string): TerminalImagePathNormalization => {
if (!urlSchemePattern.test(path)) {
return { _tag: "ValidTerminalImagePath", path }
}
if (!fileUrlPattern.test(path)) {
return { _tag: "InvalidTerminalImagePath", message: "Only file:// image URLs are supported." }
}

const rawPathname = rawFileUrlPathname(path)
if (fileUrlTraversalPattern.test(rawPathname)) {
return { _tag: "InvalidTerminalImagePath", message: "Image path must not contain '.' or '..' segments." }
}
if (encodedPathSeparatorPattern.test(rawPathname) || fileUrlBackslashPattern.test(rawPathname)) {
return {
_tag: "InvalidTerminalImagePath",
message: "Image file URL must not contain encoded or backslash path separators."
}
}

try {
const url = new URL(path)
if (url.protocol !== "file:" || (url.hostname !== "" && url.hostname !== "localhost")) {
return { _tag: "InvalidTerminalImagePath", message: "Image file URL must point to a local path." }
}
if (url.search.length > 0 || url.hash.length > 0) {
return { _tag: "InvalidTerminalImagePath", message: "Image file URL must not include query or fragment." }
}
return { _tag: "ValidTerminalImagePath", path: fileURLToPath(url, { windows: false }) }
} catch {
return { _tag: "InvalidTerminalImagePath", message: "Image file URL is invalid." }
}
}

export const planTerminalImageFetch = (path: string): TerminalImageFetchPlan => {
if (typeof path !== "string" || path.length === 0) {
return { _tag: "InvalidTerminalImageFetch", message: "Image path is required." }
}
if (!path.startsWith("/")) {
const normalized = normalizeTerminalImagePath(path)
if (normalized._tag === "InvalidTerminalImagePath") {
return { _tag: "InvalidTerminalImageFetch", message: normalized.message }
}
const containerPath = normalized.path
if (!containerPath.startsWith("/")) {
return { _tag: "InvalidTerminalImageFetch", message: "Image path must be absolute." }
}
if (invalidCharacterPattern.test(path)) {
if (invalidCharacterPattern.test(containerPath)) {
return { _tag: "InvalidTerminalImageFetch", message: "Image path contains invalid characters." }
}
if (traversalPattern.test(path)) {
if (traversalPattern.test(containerPath)) {
return { _tag: "InvalidTerminalImageFetch", message: "Image path must not contain '.' or '..' segments." }
}
const extension = lowercaseExtension(path)
const extension = lowercaseExtension(containerPath)
if (extension === null) {
return { _tag: "InvalidTerminalImageFetch", message: "Image path must include a file extension." }
}
const mediaType = supportedExtensionMediaTypes.get(extension)
if (mediaType === undefined) {
return { _tag: "InvalidTerminalImageFetch", message: `Unsupported image extension: .${extension}` }
}
return { _tag: "ValidTerminalImageFetch", containerPath: path, mediaType }
return { _tag: "ValidTerminalImageFetch", containerPath, mediaType }
}
43 changes: 42 additions & 1 deletion packages/api/tests/terminal-image-fetch-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@ import { describe, expect, it } from "@effect/vitest"
import { planTerminalImageFetch } from "../src/services/terminal-image-fetch-core.js"

describe("terminal image fetch core", () => {
it("accepts an absolute path with a supported image extension", () => {
it("continues to accept an absolute path with a supported image extension", () => {
expect(planTerminalImageFetch("/tmp/issue232-main.png")).toEqual({
_tag: "ValidTerminalImageFetch",
containerPath: "/tmp/issue232-main.png",
mediaType: "image/png"
})
})

it("accepts a file URL and normalizes it to an absolute container path", () => {
expect(planTerminalImageFetch("file:///tmp/phantom-e2e.tuhl98/wallet-step-after-password.png")).toEqual({
_tag: "ValidTerminalImageFetch",
containerPath: "/tmp/phantom-e2e.tuhl98/wallet-step-after-password.png",
mediaType: "image/png"
})
})

it("maps each supported extension to its media type", () => {
expect(planTerminalImageFetch("/a.jpg")).toMatchObject({ mediaType: "image/jpeg" })
expect(planTerminalImageFetch("/a.jpeg")).toMatchObject({ mediaType: "image/jpeg" })
Expand All @@ -33,6 +41,13 @@ describe("terminal image fetch core", () => {
})
})

it("rejects non-file URLs", () => {
expect(planTerminalImageFetch("https://example.com/tmp/photo.png")).toEqual({
_tag: "InvalidTerminalImageFetch",
message: "Only file:// image URLs are supported."
})
})

it("rejects whitespace and control characters", () => {
expect(planTerminalImageFetch("/tmp/has space.png")).toMatchObject({
_tag: "InvalidTerminalImageFetch"
Expand All @@ -51,6 +66,32 @@ describe("terminal image fetch core", () => {
})
})

it("rejects traversal segments in file URLs before URL normalization", () => {
expect(planTerminalImageFetch("file:///tmp/../etc/photo.png")).toMatchObject({
_tag: "InvalidTerminalImageFetch",
message: "Image path must not contain '.' or '..' segments."
})
expect(planTerminalImageFetch("file:///tmp/%2E%2E/etc/photo.png")).toMatchObject({
_tag: "InvalidTerminalImageFetch",
message: "Image path must not contain '.' or '..' segments."
})
})

it("rejects unsafe file URL forms", () => {
expect(planTerminalImageFetch("file://example.com/tmp/photo.png")).toMatchObject({
_tag: "InvalidTerminalImageFetch",
message: "Image file URL must point to a local path."
})
expect(planTerminalImageFetch("file:///tmp/photo.png?download=1")).toMatchObject({
_tag: "InvalidTerminalImageFetch",
message: "Image file URL must not include query or fragment."
})
expect(planTerminalImageFetch("file:///tmp/%2Fetc/photo.png")).toMatchObject({
_tag: "InvalidTerminalImageFetch",
message: "Image file URL must not contain encoded or backslash path separators."
})
})

it("rejects unsupported extensions", () => {
expect(planTerminalImageFetch("/tmp/file.bmp")).toMatchObject({
_tag: "InvalidTerminalImageFetch"
Expand Down
6 changes: 3 additions & 3 deletions packages/app/src/lib/core/templates-zsh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,9 @@ docker_git_prompt_apply() {
docker_git_terminal_sanitize
local b
b="$(docker_git_branch)"
local short_pwd
short_pwd="$(docker_git_short_pwd)"
local base="[%*] $short_pwd"
local short_path
short_path="$(docker_git_short_pwd)"
local base="[%*] $short_path"
if [[ -n "$b" ]]; then
PROMPT="$base ($b)> "
else
Expand Down
13 changes: 13 additions & 0 deletions packages/app/src/web/actions-event-payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { ApiEvent } from "./api.js"

export const readEventPayloadString = (
event: ApiEvent,
key: string
): string | null => {
const payload = event.payload
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
return null
}
const value = Object.entries(payload).find(([name]) => name === key)?.[1]
return typeof value === "string" ? value : null
}
13 changes: 1 addition & 12 deletions packages/app/src/web/actions-project-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,14 @@ import { Either } from "effect"

import { createProjectDraftFromInputs } from "../docker-git/menu-create-shared.js"
import type { CreateInputs } from "../docker-git/menu-types.js"
import { readEventPayloadString } from "./actions-event-payload.js"
import { appendOutputLine, appendOutputLineHandler, notifyProjectEventRateLimit } from "./actions-output.js"
import { type BrowserActionContext, withBusy } from "./actions-shared.js"
import { ProjectDetailsSchema } from "./api-schema.js"
import { type ApiEvent, loadProjectDetails, type ProjectDetails, startCreateProject } from "./api.js"
import { openProjectEventStream } from "./project-events.js"
import { outputScreen, projectPickerScreen } from "./screen.js"

const readEventPayloadString = (
event: ApiEvent,
key: string
): string | null => {
const payload = event.payload
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
return null
}
const value = Object.entries(payload).find(([name]) => name === key)?.[1]
return typeof value === "string" ? value : null
}

const readCreatedProjectId = (event: ApiEvent): string | null =>
event.type === "project.created" ? readEventPayloadString(event, "projectId") : null

Expand Down
Loading