Compare commits

...

8 commits

Author SHA1 Message Date
ac1ef641f5
server: fix cache expiring
Some checks failed
ci/woodpecker/push/lint-sw Pipeline failed
ci/woodpecker/push/lint-client Pipeline failed
ci/woodpecker/push/lint-foundkey-js Pipeline was successful
ci/woodpecker/push/lint-backend Pipeline failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/test Pipeline failed
2024-03-30 08:39:47 +01:00
1af0687423
server: refactor fetching private key
Especially in the case where the private key is used in an "array deliver",
it makes sense to only get the private key once instead of having the overhead
of fetching the key for each HTTP request.
2024-03-27 21:22:25 +01:00
09ff7f0c7d
client: add button to delete all shown notes in clip
This makes use of the API functionality in the backend which was introduced in
commit 89761c86ab .
2024-03-27 21:12:38 +01:00
f285281b5a
fixup! server: properly expire public key cache 2024-03-26 21:06:21 +01:00
624157f03e
server: forbid activitypub requests on unexpected routes
ActivityPub requests on routes which do not support activitypub
are now replying with HTTP status code 406 "Not Acceptable".

ActivityPub clients are required by the W3C TR to set the `Accept`
header. If this accept header is detected on an unexpected route,
the whole request will be aborted with the status code above.

This is an additional measure for clients who might not be aware of
having to check the content-type header of the reply.

Ref: https://github.com/w3c/activitypub/issues/432
Changelog: Security
2024-03-26 21:05:13 +01:00
e366116ac1
add/translate comments 2024-03-22 09:41:45 +01:00
2b5a35147a
activitypub: stop accepting collections in inbox
Changelog: Removed
2024-03-20 06:27:01 +01:00
1098b3a038
activitypub: remove sending read receipts for chat
Changelog: Removed
2024-03-20 06:10:51 +01:00
17 changed files with 69 additions and 63 deletions

View file

@ -494,6 +494,8 @@ output: "Output"
updateRemoteUser: "Update remote user information"
deleteAllFiles: "Delete all files"
deleteAllFilesConfirm: "Are you sure that you want to delete all files?"
deleteAllNotes: "Delete all notes"
deleteAllNotesConfirm: "Are you sure that you want to delete all visible notes of this clip?"
removeAllFollowing: "Unfollow all followed users"
removeAllFollowingDescription: "Executing this unfollows all accounts from {host}.\
\ Please run this if the instance e.g. no longer exists."

View file

@ -82,7 +82,7 @@ export class Cache<T> {
// Items may have been removed in the meantime or this may be
// the initial call for the first key inserted into the cache.
const [expiredKey, expiredValue] = this.cache.entries().next().value;
if (expiredValue.date + this.lifetime >= Date.now()) {
if (expiredValue.date + this.lifetime <= Date.now()) {
// This item is due for expiration, so remove it.
this.cache.delete(expiredKey);
}

View file

@ -7,6 +7,7 @@ import { Instances } from '@/models/index.js';
import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js';
import { toPuny } from '@/misc/convert-host.js';
import { StatusError } from '@/misc/fetch.js';
import { getUserKeypair } from '@/misc/keypair-store.js';
import { shouldSkipInstance } from '@/misc/skipped-instances.js';
import { DeliverJobData } from '@/queue/types.js';
@ -18,13 +19,15 @@ export default async (job: Bull.Job<DeliverJobData>) => {
if (await shouldSkipInstance(puny)) return 'skip';
const keypair = await getUserKeypair(job.data.user.id);
try {
if (Array.isArray(job.data.content)) {
await Promise.all(
job.data.content.map(x => request(job.data.user, job.data.to, x))
job.data.content.map(x => request(job.data.user, job.data.to, x, keypair))
);
} else {
await request(job.data.user, job.data.to, job.data.content);
await request(job.data.user, job.data.to, job.data.content, keypair);
}
// Update stats

View file

@ -1,10 +1,9 @@
import { IRemoteUser } from '@/models/entities/user.js';
import { toArray } from '@/prelude/array.js';
import { Resolver } from '@/remote/activitypub/resolver.js';
import { extractPunyHost } from '@/misc/convert-host.js';
import { shouldBlockInstance } from '@/misc/should-block-instance.js';
import { apLogger } from '../logger.js';
import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isCollectionOrOrderedCollection, isCollection, isFlag, isMove, getApId } from '../type.js';
import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isFlag, isMove, getApId } from '../type.js';
import create from './create/index.js';
import performDeleteActivity from './delete/index.js';
import performUpdateActivity from './update/index.js';
@ -22,23 +21,6 @@ import flag from './flag/index.js';
import { move } from './move/index.js';
export async function performActivity(actor: IRemoteUser, activity: IObject, resolver: Resolver): Promise<void> {
if (isCollectionOrOrderedCollection(activity)) {
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
const act = await resolver.resolve(item);
try {
await performOneActivity(actor, act, resolver);
} catch (err) {
if (err instanceof Error || typeof err === 'string') {
apLogger.error(err);
}
}
}
} else {
await performOneActivity(actor, activity, resolver);
}
}
async function performOneActivity(actor: IRemoteUser, activity: IObject, resolver: Resolver): Promise<void> {
if (actor.isSuspended) return;
if (typeof activity.id !== 'undefined') {

View file

@ -2,6 +2,7 @@ import { URL } from 'node:url';
import config from '@/config/index.js';
import { getUserKeypair } from '@/misc/keypair-store.js';
import { User } from '@/models/entities/user.js';
import { UserKeypair } from '@/models/entities/user-keypair.js';
import { getResponse } from '@/misc/fetch.js';
import { createSignedPost, createSignedGet } from './ap-request.js';
import { apRequestChart, federationChart, instanceChart } from '@/services/chart/index.js';
@ -14,11 +15,9 @@ import { apRequestChart, federationChart, instanceChart } from '@/services/chart
* @param url The URL of the inbox.
* @param object The Activity or other object to be posted to the inbox.
*/
export async function request(user: { id: User['id'] }, url: string, object: any): Promise<void> {
export async function request(user: { id: User['id'] }, url: string, object: any, keypair: UserKeypair): Promise<void> {
const body = JSON.stringify(object);
const keypair = await getUserKeypair(user.id);
const req = createSignedPost({
key: {
privateKeyPem: keypair.privateKey,

View file

@ -55,6 +55,18 @@ function isActivityPubReq(ctx: Router.RouterContext): boolean {
return typeof accepted === 'string' && !accepted.match(/html/);
}
export function denyActivityPub() {
return async (ctx, next) => {
if (!isActivityPubReq(ctx)) return await next();
// Clients are required to set the `Accept` header to an Activitypub content type.
// If such a content type negotiation header is received on an unexpected route,
// something seems to be fishy and we should not respond with content. A user
// might have e.g. uploaded a malicious file that looks like an activity.
ctx.status = 406;
};
}
export function setResponseType(ctx: Router.RouterContext): void {
const accept = ctx.accepts(ACTIVITY_JSON, LD_JSON);
if (accept === LD_JSON) {

View file

@ -10,7 +10,6 @@ import { toArray } from '@/prelude/array.js';
import { renderReadActivity } from '@/remote/activitypub/renderer/read.js';
import { renderActivity } from '@/remote/activitypub/renderer/index.js';
import { deliver } from '@/queue/index.js';
import orderedCollection from '@/remote/activitypub/renderer/ordered-collection.js';
/**
* Mark messages as read
@ -133,18 +132,3 @@ export async function readGroupMessagingMessage(
}
}
}
export async function deliverReadActivity(user: { id: User['id']; host: null; }, recipient: IRemoteUser, messages: MessagingMessage | MessagingMessage[]) {
const contents = toArray(messages)
.filter(x => x.uri)
.map(x => renderReadActivity(user, x));
if (contents.length > 1) {
const collection = orderedCollection(null, contents.length, undefined, undefined, contents);
deliver(user, renderActivity(collection), recipient.inbox);
} else {
for (const content of contents) {
deliver(user, renderActivity(content), recipient.inbox);
}
}
}

View file

@ -83,7 +83,7 @@ export default define(meta, paramDef, async (ps, me) => {
});
/***
* URIからUserかNoteを解決する
* Resolve a User or Note from a given URI
*/
async function fetchAny(uri: string, me: ILocalUser | null | undefined): Promise<SchemaType<typeof meta['res']> | null> {
// Stop if the host is blocked.
@ -92,6 +92,7 @@ async function fetchAny(uri: string, me: ILocalUser | null | undefined): Promise
return null;
}
// first try to fetch the object from the database
const dbResolver = new DbResolver();
let local = await mergePack(me, ...await Promise.all([
@ -100,13 +101,15 @@ async function fetchAny(uri: string, me: ILocalUser | null | undefined): Promise
]));
if (local != null) return local;
// fetch object from remote
// getting the object from the database failed, fetch from remote
const resolver = new Resolver();
// allow redirect
const object = await resolver.resolve(uri, true) as any;
// /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する
// これはDBに存在する可能性があるため再度DB検索
// If a URI other than the canonical id such as `/@user` is specified,
// the canonical URI is determined here for the first time.
//
// DB search again, since this may exist in the DB
if (uri !== object.id) {
local = await mergePack(me, ...await Promise.all([
dbResolver.getUserFromApId(object.id),

View file

@ -35,6 +35,14 @@ export const paramDef = {
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
/*
Note: It should not be allowed for the actual file contents to be updated.
Not allowing the user to change the contents after the public URL has been determined
is relevant because it is a defense mechanism against AcitivtyPub content "impersonation".
If the URL is known, an integrity check could be defeated which checks that the `id`
indicated in an ActivityPub object is actually retrievable at that given `id`.
*/
const file = await DriveFiles.findOneBy({ id: ps.fileId });
if (file == null) throw new ApiError('NO_SUCH_FILE');

View file

@ -4,7 +4,7 @@ import define from '@/server/api/define.js';
import { ApiError } from '@/server/api/error.js';
import { getUser } from '@/server/api/common/getters.js';
import { makePaginationQuery } from '@/server/api/common/make-pagination-query.js';
import { readUserMessagingMessage, readGroupMessagingMessage, deliverReadActivity } from '@/server/api/common/read-messaging-message.js';
import { readUserMessagingMessage, readGroupMessagingMessage } from '@/server/api/common/read-messaging-message.js';
export const meta = {
tags: ['messaging'],
@ -75,11 +75,6 @@ export default define(meta, paramDef, async (ps, user) => {
// Mark all as read
if (ps.markAsRead) {
readUserMessagingMessage(user.id, recipient.id, messages.filter(m => m.recipientId === user.id).map(x => x.id));
// リモートユーザーとのメッセージだったら既読配信
if (Users.isLocalUser(user) && Users.isRemoteUser(recipient)) {
deliverReadActivity(user, recipient, messages);
}
}
return await Promise.all(messages.map(message => MessagingMessages.pack(message, user, {

View file

@ -10,6 +10,7 @@ import cors from '@koa/cors';
import { Instances, AccessTokens, Users } from '@/models/index.js';
import config from '@/config/index.js';
import { denyActivityPub } from '@/server/activitypub.js';
import { endpoints } from './endpoints.js';
import { handler } from './api-handler.js';
import signup from './private/signup.js';
@ -24,6 +25,7 @@ const app = new Koa();
app.use(cors({
origin: '*',
}));
app.use(denyActivityPub());
// No caching
app.use(async (ctx, next) => {

View file

@ -1,7 +1,7 @@
import { UserGroupJoinings, Users, MessagingMessages } from '@/models/index.js';
import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js';
import { UserGroup } from '@/models/entities/user-group.js';
import { readUserMessagingMessage, readGroupMessagingMessage, deliverReadActivity } from '@/server/api/common/read-messaging-message.js';
import { readUserMessagingMessage, readGroupMessagingMessage } from '@/server/api/common/read-messaging-message.js';
import Channel from '@/server/api/stream/channel.js';
import { StreamMessages } from '@/server/api/stream/types.js';
@ -69,13 +69,6 @@ export default class extends Channel {
case 'read':
if (this.otherpartyId) {
readUserMessagingMessage(this.user!.id, this.otherpartyId, [body.id]);
// リモートユーザーからのメッセージだったら既読配信
if (Users.isLocalUser(this.user!) && Users.isRemoteUser(this.otherparty!)) {
MessagingMessages.findOneBy({ id: body.id }).then(message => {
if (message) deliverReadActivity(this.user as ILocalUser, this.otherparty as IRemoteUser, message);
});
}
} else if (this.groupId) {
readGroupMessagingMessage(this.user!.id, this.groupId, [body.id]);
}

View file

@ -8,6 +8,7 @@ import { dirname } from 'node:path';
import Koa from 'koa';
import cors from '@koa/cors';
import Router from '@koa/router';
import { denyActivityPub } from '@/server/activitypub.js';
import { sendDriveFile } from './send-drive-file.js';
const _filename = fileURLToPath(import.meta.url);
@ -16,6 +17,7 @@ const _dirname = dirname(_filename);
// Init app
const app = new Koa();
app.use(cors());
app.use(denyActivityPub());
app.use(async (ctx, next) => {
ctx.set('Content-Security-Policy', "default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'");
await next();

View file

@ -5,11 +5,13 @@
import Koa from 'koa';
import cors from '@koa/cors';
import Router from '@koa/router';
import { denyActivityPub } from '@/server/activitypub.js';
import { proxyMedia } from './proxy-media.js';
// Init app
const app = new Koa();
app.use(cors());
app.use(denyActivityPub());
app.use(async (ctx, next) => {
ctx.set('Content-Security-Policy', "default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'");
await next();

View file

@ -11,7 +11,7 @@ const cache = new Cache<Instance>(
if (host == null) return undefined;
const res = await Instances.findOneBy({ host });
return res ?? undefined;
} ,
},
);
export async function registerOrFetchInstanceDoc(idnHost: string): Promise<Instance> {

View file

@ -1,6 +1,6 @@
import { IsNull } from 'typeorm';
import { ILocalUser, User } from '@/models/entities/user.js';
import { Users } from '@/models/index.js';
import { Users, UserPublickeys } from '@/models/index.js';
import { Cache } from '@/misc/cache.js';
import { subscriber } from '@/db/redis.js';
import { MINUTE } from '@/const.js';

View file

@ -12,7 +12,7 @@
</div>
</div>
<XNotes :pagination="pagination" :detail="true"/>
<XNotes ref="tlComponent" :pagination="pagination" :detail="true"/>
</div>
</MkSpacer>
</MkStickyContainer>
@ -39,6 +39,7 @@ const pagination = {
clipId: props.clipId,
})),
};
const tlComponent = $ref();
const isOwned: boolean | null = $computed<boolean | null>(() => $i && clip && ($i.id === clip.userId));
@ -52,7 +53,25 @@ watch(() => props.clipId, async () => {
provide('currentClipPage', $$(clip));
const headerActions = $computed(() => clip && isOwned ? [{
const headerActions = $computed(() => clip && isOwned ? [
...($i.isAdmin ? [{
icon: 'fas fa-dumpster',
text: i18n.ts.deleteAllNotes,
handler: async (): Promise<void> => {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.deleteAllNotesConfirm,
});
if (canceled) return;
await os.apiWithDialog('notes/delete-many', {
noteIds: tlComponent.pagingComponent?.items.map(item => item.id)
});
tlComponent.pagingComponent?.reload();
},
}] : []),
{
icon: 'fas fa-pencil-alt',
text: i18n.ts.edit,
handler: async (): Promise<void> => {