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
24 changes: 22 additions & 2 deletions app/components/Header/AccountMenu.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,17 @@ function openAuthModal() {
</span>

<!-- Atmosphere avatar (second/front, overlapping) -->
<img
v-if="atprotoUser?.avatar"
:src="atprotoUser.avatar"
:alt="atprotoUser.handle"
width="24"
height="24"
class="w-6 h-6 rounded-full ring-2 ring-bg"
:class="hasBothConnections ? 'relative z-10' : ''"
/>
<span
v-if="atprotoUser"
v-else-if="atprotoUser"
class="w-6 h-6 rounded-full bg-bg-muted ring-2 ring-bg flex items-center justify-center"
:class="hasBothConnections ? 'relative z-10' : ''"
>
Expand Down Expand Up @@ -181,7 +190,18 @@ function openAuthModal() {
class="w-full px-3 py-2.5 flex items-center gap-3 hover:bg-bg-subtle transition-colors text-start"
@click="openAuthModal"
>
<span class="w-8 h-8 rounded-full bg-bg-muted flex items-center justify-center">
<img
v-if="atprotoUser.avatar"
:src="atprotoUser.avatar"
:alt="atprotoUser.handle"
width="32"
height="32"
class="w-8 h-8 rounded-full"
/>
<span
v-else
class="w-8 h-8 rounded-full bg-bg-muted flex items-center justify-center"
>
<span class="i-carbon-cloud w-4 h-4 text-fg-muted" aria-hidden="true" />
</span>
<div class="flex-1 min-w-0">
Expand Down
39 changes: 38 additions & 1 deletion server/api/auth/atproto.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ import { SLINGSHOT_HOST } from '#shared/utils/constants'
import { useServerSession } from '#server/utils/server-session'
import type { PublicUserSession } from '#shared/schemas/publicUserSession'

interface ProfileRecord {
avatar?: {
$type: 'blob'
ref: { $link: string }
mimeType: string
size: number
}
}

export default defineEventHandler(async event => {
const config = useRuntimeConfig(event)
if (!config.sessionPassword) {
Expand Down Expand Up @@ -58,8 +67,36 @@ export default defineEventHandler(async event => {
)
if (response.ok) {
const miniDoc: PublicUserSession = await response.json()

// Fetch the user's profile record to get their avatar blob reference
let avatar: string | undefined
const did = agent.did
try {
const pdsUrl = new URL(miniDoc.pds)
// Only fetch from HTTPS PDS endpoints to prevent SSRF
if (did && pdsUrl.protocol === 'https:') {
const profileResponse = await fetch(
`${pdsUrl.origin}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`,
{ headers: { 'User-Agent': 'npmx' } },
)
if (profileResponse.ok) {
const record = (await profileResponse.json()) as { value: ProfileRecord }
const avatarBlob = record.value.avatar
if (avatarBlob?.ref?.$link) {
// Use Bluesky CDN for faster image loading
avatar = `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${avatarBlob.ref.$link}@jpeg`
}
}
Comment on lines 71 to 89
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's read the file to understand the full context
wc -l server/api/auth/atproto.get.ts

Repository: npmx-dev/npmx.dev

Length of output: 95


🏁 Script executed:

# Read the entire file to understand variable declarations and context
cat -n server/api/auth/atproto.get.ts

Repository: npmx-dev/npmx.dev

Length of output: 3923


🏁 Script executed:

# Find the PublicUserSession type definition
fd -t f "publicUserSession" --exec cat {}

Repository: npmx-dev/npmx.dev

Length of output: 438


🏁 Script executed:

# Search for PublicUserSession type in shared directory
rg "interface PublicUserSession|type PublicUserSession" -A 10

Repository: npmx-dev/npmx.dev

Length of output: 172


🏁 Script executed:

# Check the Agent type from `@atproto/api` to see if did is optional
rg "class Agent|interface Agent" -A 20 | head -50

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Search for any SSRF or URL validation patterns in the codebase
rg "ssrf|SSRF|private.*host|localhost|127.0.0.1" -i

Repository: npmx-dev/npmx.dev

Length of output: 14997


🏁 Script executed:

# Check what valibot's url() validator does and how it's used
rg "from 'valibot'" -B 2 -A 2

Repository: npmx-dev/npmx.dev

Length of output: 8676


🏁 Script executed:

# Check the Agent type and its did property
rg "class Agent|did\?" -A 5 | grep -A 5 "class Agent" | head -20

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Search for what valibot's url() validator does - check documentation or usage
rg "url\(\)" -B 3 -A 3 | head -40

Repository: npmx-dev/npmx.dev

Length of output: 1870


🏁 Script executed:

# Check if miniDoc validation happens on line 69
sed -n '64,70p' server/api/auth/atproto.get.ts

Repository: npmx-dev/npmx.dev

Length of output: 324


🏁 Script executed:

# Check if PublicUserSessionSchema is used to validate miniDoc anywhere
rg "PublicUserSessionSchema" -B 2 -A 2

Repository: npmx-dev/npmx.dev

Length of output: 1119


🏁 Script executed:

# Look at atproto Agent type - search npm packages or type definitions in node_modules
# First, let's see if there are any .d.ts files or type definitions
fd -e "d.ts" "@atproto" 2>/dev/null | head -5

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Check if agent.did can be undefined by looking at the Agent constructor
rg "@atproto/api" -A 10 | grep -A 10 "Agent"

Repository: npmx-dev/npmx.dev

Length of output: 914


🏁 Script executed:

# Check if miniDoc is validated using safeParse in atproto.get.ts
sed -n '58,75p' server/api/auth/atproto.get.ts

Repository: npmx-dev/npmx.dev

Length of output: 763


🏁 Script executed:

# Search for any Agent type definitions or usage to understand if did is optional
rg "did\?" -i -B 2 -A 2 | head -50

Repository: npmx-dev/npmx.dev

Length of output: 551


🏁 Script executed:

# Check if miniDoc is ever validated with safeParse in the atproto file
rg "safeParse.*miniDoc|miniDoc.*safeParse" server/api/auth/atproto.get.ts

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Look for all uses of agent.did to see if it's ever checked for undefined
rg "agent\.did" server/api/auth/atproto.get.ts -B 2 -A 2

Repository: npmx-dev/npmx.dev

Length of output: 761


🏁 Script executed:

# Let's verify if valibot's url() validator provides any SSRF protection
# by checking what it validates
web_search "valibot url validator what does it validate"

Repository: npmx-dev/npmx.dev

Length of output: 111


🏁 Script executed:

# Check if there's any validation of miniDoc using safeParse in the complete file
cat -n server/api/auth/atproto.get.ts | grep -A 5 -B 5 "safeParse\|parse"

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Let's also check the complete context around agent initialization
sed -n '58,77p' server/api/auth/atproto.get.ts

Repository: npmx-dev/npmx.dev

Length of output: 947


🌐 Web query:

valibot url validator what does it check validate

💡 Result:

Valibot’s url() only validates that a string is “badly formatted” vs “validly formatted” as a URL (i.e., it’s a URL format check on a string, returning a url validation issue if it isn’t). [1]

What it does not check by itself:

  • Protocol safety / allowlist (e.g., it won’t inherently block javascript:...; you must add your own constraints). [2]
  • Specific scheme requirements like “must be https” (you’d add an additional rule such as startsWith('https://')). [3]
  • Host/domain allowlists, URL reachability, DNS/HTTP status, etc. (those are outside “formatting”). [1]

Sources:
[1] Valibot url action docs: https://valibot.dev/api/url/
[2] Discussion referencing javascript: concerns with extending url: open-circle/valibot#835
[3] Valibot startsWith docs (HTTPS URL schema example): https://valibot.dev/api/startsWith/


🏁 Script executed:

# Check if miniDoc is cast without validation (on line 69)
sed -n '68,70p' server/api/auth/atproto.get.ts

Repository: npmx-dev/npmx.dev

Length of output: 143


🏁 Script executed:

# Look for any valibot documentation comments or usage patterns in the codebase
rg "valibot.*url|url.*valibot" -A 3 -B 3 | head -40

Repository: npmx-dev/npmx.dev

Length of output: 617


Validate miniDoc.pds and guard agent.did before server-side fetches.

miniDoc.pds is cast to type without validation—valibot's url() only checks format, not SSRF protections. This allows fetches to localhost, private IPs, or restricted hosts. Additionally, agent.did! uses a non-null assertion without checking if it's defined, bypassing type safety.

Before fetching from the user's PDS, ensure you:

  • Validate miniDoc against PublicUserSessionSchema using safeParse()
  • Check agent.did is defined before using it in URLs
  • Restrict PDS URLs to HTTPS and exclude private hosts
🔒 Suggested guard + validation
-      const profileResponse = await fetch(
-        `${miniDoc.pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(agent.did!)}&collection=app.bsky.actor.profile&rkey=self`,
-        { headers: { 'User-Agent': 'npmx' } },
-      )
-      if (profileResponse.ok) {
-        const record = (await profileResponse.json()) as { value: ProfileRecord }
-        const avatarBlob = record.value.avatar
-        if (avatarBlob?.ref?.$link) {
-          // Construct the blob URL from the user's PDS
-          avatar = `${miniDoc.pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(agent.did!)}&cid=${encodeURIComponent(avatarBlob.ref.$link)}`
-        }
-      }
+      const did = agent.did
+      const pdsUrl = new URL(miniDoc.pds)
+      if (did && pdsUrl.protocol === 'https:' /* && isPublicHost(pdsUrl.hostname) */) {
+        const profileResponse = await fetch(
+          `${pdsUrl.origin}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`,
+          { headers: { 'User-Agent': 'npmx' } },
+        )
+        if (profileResponse.ok) {
+          const record = (await profileResponse.json()) as { value: ProfileRecord }
+          const avatarBlob = record.value.avatar
+          if (avatarBlob?.ref?.$link) {
+            avatar = `${pdsUrl.origin}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(avatarBlob.ref.$link)}`
+          }
+        }
+      }

}
} catch {
Copy link
Contributor

@fatfingers23 fatfingers23 Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This catch will happen a good bit for our npmx.social users. They do not get a app.bsky.actor.profile on signup. Nothing we have to change now since the cloud will be good back up still

// Avatar fetch failed, continue without it
}

await session.update({
public: miniDoc,
public: {
...miniDoc,
avatar,
},
})
}

Expand Down
3 changes: 2 additions & 1 deletion shared/schemas/publicUserSession.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { object, string, pipe, url } from 'valibot'
import { object, string, pipe, url, optional } from 'valibot'
import type { InferOutput } from 'valibot'

export const PublicUserSessionSchema = object({
// Safe to pass to the frontend
did: string(),
handle: string(),
pds: pipe(string(), url()),
avatar: optional(pipe(string(), url())),
})

export type PublicUserSession = InferOutput<typeof PublicUserSessionSchema>
1 change: 1 addition & 0 deletions shared/types/userSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface UserServerSession {
did: string
handle: string
pds: string
avatar?: string
}
// Only to be used in the atproto session and state stores
// Will need to change to Record<string, T> and add a current logged in user if we ever want to support
Expand Down
Loading