Skip to content

Commit ab3f83b

Browse files
hmndschpet
authored andcommitted
fix(issue-view): don't send auth headers to non-linear domains
1 parent c05f6fc commit ab3f83b

3 files changed

Lines changed: 47 additions & 12 deletions

File tree

src/commands/issue/issue-view.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import {
2323
} from "../../utils/hyperlink.ts"
2424
import { createHyperlinkExtension } from "../../utils/charmd-hyperlink-extension.ts"
2525
import { handleError, ValidationError } from "../../utils/errors.ts"
26+
import {
27+
LINEAR_PRIVATE_UPLOAD_HOST,
28+
LINEAR_UPLOAD_HOSTNAMES,
29+
} from "../../const.ts"
2630

2731
export const viewCommand = new Command()
2832
.name("view")
@@ -476,11 +480,7 @@ export function extractLinearLinkInfo(
476480

477481
visit(tree, "link", (node: Link) => {
478482
// Only extract links to Linear uploads
479-
if (
480-
node.url &&
481-
(node.url.includes("uploads.linear.app") ||
482-
node.url.includes("public.linear.app"))
483-
) {
483+
if (node.url && getLinearUploadHost(node.url)) {
484484
// Get link text from first child if it's a text node
485485
const textNode = node.children[0]
486486
const text = textNode && textNode.type === "text" ? textNode.value : null
@@ -533,6 +533,15 @@ export async function getUrlHash(url: string): Promise<string> {
533533
return encodeHex(hashArray).substring(0, 16)
534534
}
535535

536+
export function getLinearUploadHost(url: string): string | null {
537+
try {
538+
const { hostname } = new URL(url)
539+
return LINEAR_UPLOAD_HOSTNAMES.includes(hostname) ? hostname : null
540+
} catch {
541+
return null
542+
}
543+
}
544+
536545
/**
537546
* download an image to the cache directory if not already cached
538547
* returns the local file path
@@ -556,7 +565,7 @@ async function downloadImage(
556565
}
557566

558567
const headers: Record<string, string> = {}
559-
if (url.includes("uploads.linear.app")) {
568+
if (getLinearUploadHost(url) === LINEAR_PRIVATE_UPLOAD_HOST) {
560569
const apiKey = getResolvedApiKey()
561570
if (apiKey) {
562571
headers["Authorization"] = apiKey
@@ -674,10 +683,8 @@ async function downloadAttachments(
674683
for (const attachment of attachments) {
675684
try {
676685
// Skip non-file URLs (e.g., external links)
677-
// Linear uses uploads.linear.app for private and public.linear.app for public images
678-
const isLinearUpload = attachment.url.includes("uploads.linear.app") ||
679-
attachment.url.includes("public.linear.app")
680-
if (!isLinearUpload) {
686+
const uploadHost = getLinearUploadHost(attachment.url)
687+
if (!uploadHost) {
681688
continue
682689
}
683690

@@ -694,8 +701,7 @@ async function downloadAttachments(
694701
}
695702

696703
const headers: Record<string, string> = {}
697-
// Only add auth header for private uploads, not public URLs
698-
if (attachment.url.includes("uploads.linear.app")) {
704+
if (uploadHost === LINEAR_PRIVATE_UPLOAD_HOST) {
699705
const apiKey = getResolvedApiKey()
700706
if (apiKey) {
701707
headers["Authorization"] = apiKey

src/const.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,11 @@
11
export const LINEAR_WEB_BASE_URL = "https://linear.app"
22
export const LINEAR_API_ENDPOINT = "https://api.linear.app/graphql"
3+
4+
/** Requires auth to access. */
5+
export const LINEAR_PRIVATE_UPLOAD_HOST = "uploads.linear.app"
6+
export const LINEAR_PUBLIC_UPLOAD_HOST = "public.linear.app"
7+
8+
export const LINEAR_UPLOAD_HOSTNAMES: readonly string[] = [
9+
LINEAR_PRIVATE_UPLOAD_HOST,
10+
LINEAR_PUBLIC_UPLOAD_HOST,
11+
]

test/commands/issue/image-download.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { assertEquals } from "@std/assert"
22
import {
33
extractImageInfo,
4+
extractLinearLinkInfo,
45
getUrlHash,
56
replaceImageUrls,
67
} from "../../../src/commands/issue/issue-view.ts"
@@ -109,6 +110,25 @@ Deno.test("replaceImageUrls - handles empty map", async () => {
109110
assertEquals(result.includes("https://example.com/img.png"), true)
110111
})
111112

113+
Deno.test("extractLinearLinkInfo - extracts Linear upload links", () => {
114+
const md = "See [file](https://uploads.linear.app/abc/doc.pdf)"
115+
const links = extractLinearLinkInfo(md)
116+
assertEquals(links.length, 1)
117+
assertEquals(links[0].url, "https://uploads.linear.app/abc/doc.pdf")
118+
})
119+
120+
Deno.test("extractLinearLinkInfo - ignores spoofed domain in path", () => {
121+
const md = "See [file](https://example.com/uploads.linear.app/doc.pdf)"
122+
const links = extractLinearLinkInfo(md)
123+
assertEquals(links.length, 0)
124+
})
125+
126+
Deno.test("extractLinearLinkInfo - ignores spoofed subdomain", () => {
127+
const md = "See [file](https://uploads.linear.app.example.com/doc.pdf)"
128+
const links = extractLinearLinkInfo(md)
129+
assertEquals(links.length, 0)
130+
})
131+
112132
// Hyperlink utility tests
113133

114134
Deno.test("hyperlink - creates OSC-8 escape sequence", () => {

0 commit comments

Comments
 (0)