Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6990cc0
ci: gate image build on CI success
sasagar Oct 14, 2026
e2a024c
fix(api): strip private fields from public user responses
sasagar Oct 14, 2026
f26a7d4
Merge pull request #290 from Love-Rox/fix/strip-private-key-from-user…
sasagar Oct 14, 2026
1f88657
Merge pull request #289 from Love-Rox/fix/ci-gate-image-build
sasagar Oct 14, 2026
d5e3818
fix(security): block SVG XSS in media proxy and add SSRF protection
sasagar Oct 14, 2026
de32102
fix(federation): bind HTTP signatures to verified key owner and enfor…
sasagar Oct 14, 2026
ac0d8d2
fix(security): block OAuth email takeover, gate OAuth sign-up, drop l…
sasagar Oct 14, 2026
768c035
Merge pull request #291 from Love-Rox/fix/proxy-xss-ssrf
sasagar Oct 14, 2026
9c9b3d3
fix(security): enforce note visibility for followers-only and specifi…
sasagar Oct 14, 2026
641e9db
Merge origin/dev into fix/oauth-registration-uri
sasagar Oct 14, 2026
eadeed8
Merge origin/dev into fix/note-visibility
sasagar Oct 14, 2026
79697e5
Merge pull request #293 from Love-Rox/fix/oauth-registration-uri
sasagar Oct 14, 2026
5474231
Merge pull request #294 from Love-Rox/fix/note-visibility
sasagar Oct 14, 2026
a8bfbbc
Merge remote-tracking branch 'origin/dev' into fix/federation-actor-b…
sasagar Oct 14, 2026
01687f1
fix(federation): verify key owner via actor document, harden Announce
sasagar Oct 14, 2026
bbd1234
Merge pull request #292 from Love-Rox/fix/federation-actor-binding
sasagar Oct 14, 2026
c5c0695
chore: bump version to 2026.10.2
sasagar Oct 14, 2026
3255616
chore: update bun.lock for 2026.10.2
sasagar Oct 14, 2026
79338d0
Merge pull request #295 from Love-Rox/release/v2026.10.2
sasagar Oct 14, 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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ SESSION_EXPIRY_DAYS=30
# Not currently used but reserved for future JWT-based auth
# JWT_SECRET=your-secure-random-secret-here

# Allow outbound fetches (federation, media proxy, WebPush, nodeinfo) to
# private / loopback / link-local addresses (default: false).
# SECURITY: only enable for local development or federation testing on
# localhost. Never enable in production (SSRF risk).
# ALLOW_PRIVATE_NETWORK_FETCH=false

# =============================================================================
# Registration & Moderation
# =============================================================================
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/build-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ name: Build & Push Images
# Note: we intentionally do NOT publish a moving `:latest` tag. The cluster
# tracks the explicit `:dev` tag, and avoiding `:latest` sidesteps the tag
# resolution ambiguity that previously affected keel on the ikaskey instance.
#
# CI gate: images are only built after ci.yml passes for the same commit.
# keel auto-deploys :dev, so publishing an image from a failing commit would
# ship it to production (#288).

on:
push:
Expand All @@ -25,8 +29,12 @@ env:
IMAGE_NAMESPACE: love-rox

jobs:
ci:
uses: ./.github/workflows/ci.yml

build:
name: Build ${{ matrix.component }}
needs: ci
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ on:
push:
branches-ignore:
- 'dependabot/**'
# dev / main run CI via build-images.yml (workflow_call), which gates
# image publishing on the result. Skip here to avoid running CI twice.
- dev
- main
pull_request:
branches: [main, dev]
workflow_call:
Expand Down
6 changes: 3 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "rox",
"version": "2026.10.1",
"version": "2026.10.2",
"license": "AGPL-3.0-only",
"devDependencies": {
"@playwright/test": "^1.64.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hono_rox",
"version": "1.10.1",
"version": "1.10.2",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
Expand Down
23 changes: 23 additions & 0 deletions packages/backend/src/interfaces/repositories/INoteRepository.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
import type { Note } from "shared";

/**
* Viewer-aware visibility filter for note list queries.
*
* When supplied, repositories only return notes the viewer is allowed to see
* (see `lib/noteVisibility.ts` for the rules):
* - `public` / `home`: always
* - `followers`: author, followers of the author (accepted follow),
* mentioned users, or the author of the replied-to note
* - `specified`: author or recipients (stored in `mentions`)
*
* A `viewerId` of `null` represents an unauthenticated viewer and restricts
* results to `public` / `home` notes.
*/
export interface NoteVisibilityFilter {
/** Viewing user ID, or null for unauthenticated viewers */
viewerId: string | null;
}

export interface TimelineOptions {
limit?: number;
sinceId?: string;
untilId?: string;
userIds?: string[];
/**
* Restrict results to notes visible to this viewer.
* When omitted, no visibility filtering is applied (trusted/internal use only).
*/
visibleTo?: NoteVisibilityFilter;
}

/**
Expand Down
17 changes: 9 additions & 8 deletions packages/backend/src/lib/instance-icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { logger } from "./logger.js";
import { safeFetch } from "./safeFetch.js";

/**
* Try to fetch instance icon from software-specific APIs, then fall back to favicon
Expand Down Expand Up @@ -56,7 +57,7 @@ export async function fetchInstanceIconFromApi(
normalizedName === "calckey" ||
normalizedName === "cherrypick"
) {
const response = await fetch(`https://${host}/api/meta`, {
const response = await safeFetch(`https://${host}/api/meta`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Expand Down Expand Up @@ -85,7 +86,7 @@ export async function fetchInstanceIconFromApi(
normalizedName === "glitch" ||
normalizedName === "kmyblue"
) {
const response = await fetch(`https://${host}/api/v2/instance`, {
const response = await safeFetch(`https://${host}/api/v2/instance`, {
headers: {
Accept: "application/json",
},
Expand All @@ -102,7 +103,7 @@ export async function fetchInstanceIconFromApi(
}

// Fall back to v1 API for older Mastodon versions
const v1Response = await fetch(`https://${host}/api/v1/instance`, {
const v1Response = await safeFetch(`https://${host}/api/v1/instance`, {
headers: {
Accept: "application/json",
},
Expand All @@ -119,7 +120,7 @@ export async function fetchInstanceIconFromApi(

// Pleroma / Akkoma
if (normalizedName === "pleroma" || normalizedName === "akkoma") {
const response = await fetch(`https://${host}/api/v1/instance`, {
const response = await safeFetch(`https://${host}/api/v1/instance`, {
headers: {
Accept: "application/json",
},
Expand All @@ -136,7 +137,7 @@ export async function fetchInstanceIconFromApi(

// GoToSocial
if (normalizedName === "gotosocial") {
const response = await fetch(`https://${host}/api/v1/instance`, {
const response = await safeFetch(`https://${host}/api/v1/instance`, {
headers: {
Accept: "application/json",
},
Expand All @@ -155,7 +156,7 @@ export async function fetchInstanceIconFromApi(
if (!normalizedName) {
// Try Mastodon API first (most common)
try {
const response = await fetch(`https://${host}/api/v1/instance`, {
const response = await safeFetch(`https://${host}/api/v1/instance`, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(5000),
});
Expand All @@ -172,7 +173,7 @@ export async function fetchInstanceIconFromApi(

// Try Misskey API
try {
const response = await fetch(`https://${host}/api/meta`, {
const response = await safeFetch(`https://${host}/api/meta`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Expand Down Expand Up @@ -217,7 +218,7 @@ export async function fetchFavicon(host: string): Promise<string | null> {
for (const path of faviconPaths) {
try {
const url = `https://${host}${path}`;
const response = await fetch(url, {
const response = await safeFetch(url, {
method: "HEAD",
signal: AbortSignal.timeout(5000),
});
Expand Down
142 changes: 142 additions & 0 deletions packages/backend/src/lib/noteVisibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Note Visibility Rules
*
* Central implementation of Misskey-compatible note visibility semantics.
* Every code path that returns note content to a viewer (API, ActivityPub,
* oEmbed, streaming) must go through these helpers (or the equivalent
* repository-level `visibleTo` filter) so that followers-only and
* specified (direct) notes are never exposed to unauthorized viewers.
*
* Rules:
* - `public` / `home`: visible to anyone, including unauthenticated viewers
* - `followers`: visible to the author, users who follow the author,
* users mentioned in the note, and the author of the note being replied to
* - `specified`: visible to the author and the recipients
* (stored in the note's `mentions` array, which holds `visibleUserIds`)
*
* @module lib/noteVisibility
*/

import type { IFollowRepository } from "../interfaces/repositories/IFollowRepository.js";
import type { INoteRepository } from "../interfaces/repositories/INoteRepository.js";

/**
* Minimal note shape required to evaluate visibility.
*/
export interface NoteVisibilityFields {
/** Author user ID */
userId: string;
/** Visibility level (public, home, followers, specified) */
visibility: string;
/** Mentioned user IDs (for `specified` notes, the recipient user IDs) */
mentions?: string[] | null;
/** ID of the note this note replies to */
replyId?: string | null;
}

/**
* Additional relationship facts between the viewer and the note,
* used to evaluate `followers` visibility.
*/
export interface NoteViewerRelation {
/** Whether the viewer follows the note author */
isFollowing: boolean;
/** Whether the viewer is the author of the note being replied to */
isReplyTargetAuthor?: boolean;
}

/**
* Check whether a note is visible to everyone (including unauthenticated viewers).
*
* @param note - Note to check
* @returns True for `public` and `home` notes
*/
export function isPubliclyVisible(note: Pick<NoteVisibilityFields, "visibility">): boolean {
return note.visibility === "public" || note.visibility === "home";
}

/**
* Pure visibility check for a note and a viewer.
*
* @param note - Note to check
* @param viewerId - Viewing user ID, or null for unauthenticated viewers
* @param relation - Whether the viewer follows the author (boolean shorthand),
* or a full relation object
* @returns True if the viewer may see the note
*
* @example
* ```typescript
* const isFollowing = await followRepository.exists(viewerId, note.userId);
* if (!canViewNote(note, viewerId, isFollowing)) {
* return c.json({ error: "Note not found" }, 404);
* }
* ```
*/
export function canViewNote(
note: NoteVisibilityFields,
viewerId: string | null | undefined,
relation: boolean | NoteViewerRelation = false,
): boolean {
if (isPubliclyVisible(note)) return true;
if (!viewerId) return false;
if (note.userId === viewerId) return true;

const rel: NoteViewerRelation =
typeof relation === "boolean" ? { isFollowing: relation } : relation;
const isMentioned = (note.mentions ?? []).includes(viewerId);

if (note.visibility === "specified") {
return isMentioned;
}

if (note.visibility === "followers") {
return rel.isFollowing || isMentioned || rel.isReplyTargetAuthor === true;
}

// Unknown visibility values are treated as private
return false;
}

/**
* Dependencies required by {@link isNoteVisibleTo}.
*/
export interface NoteVisibilityDeps {
/** Follow repository used to check whether the viewer follows the author */
followRepository: Pick<IFollowRepository, "exists">;
/** Optional note repository used to resolve the reply target author */
noteRepository?: Pick<INoteRepository, "findById">;
}

/**
* Asynchronous visibility check that resolves relationship facts on demand.
*
* Only performs database lookups when the result cannot be determined
* from the note itself (i.e. `followers` notes viewed by a non-author
* who is not mentioned).
*
* @param note - Note to check
* @param viewerId - Viewing user ID, or null for unauthenticated viewers
* @param deps - Repositories used to resolve relationships
* @returns True if the viewer may see the note
*/
export async function isNoteVisibleTo(
note: NoteVisibilityFields,
viewerId: string | null | undefined,
deps: NoteVisibilityDeps,
): Promise<boolean> {
if (canViewNote(note, viewerId, false)) return true;
if (!viewerId || note.visibility !== "followers") return false;

if (await deps.followRepository.exists(viewerId, note.userId)) {
return true;
}

if (note.replyId && deps.noteRepository) {
const parent = await deps.noteRepository.findById(note.replyId);
if (parent && parent.userId === viewerId) {
return true;
}
}

return false;
}
Loading
Loading