Skip to content

Commit

Permalink
ActivityPubでリモートのオブジェクトをGETするときのリクエストをHTTP Signatureで署名するオプション (#6731)
Browse files Browse the repository at this point in the history
* Sign ActivityPub GET

* Fix v12, v12.48.0 UI bug
  • Loading branch information
mei23 authored Oct 17, 2020
1 parent ba3c62b commit 85a0f69
Show file tree
Hide file tree
Showing 10 changed files with 298 additions and 8 deletions.
3 changes: 3 additions & 0 deletions .config/example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,6 @@ id: 'aid'

# Media Proxy
#mediaProxy: https://example.com/proxy

# Sign to ActivityPub GET request (default: false)
#signToActivityPubGet: true
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
"file-type": "15.0.1",
"fluent-ffmpeg": "2.1.2",
"glob": "7.1.6",
"got": "11.7.0",
"gulp": "4.0.2",
"gulp-rename": "2.0.0",
"gulp-replace": "1.0.0",
Expand Down
8 changes: 6 additions & 2 deletions src/client/pages/follow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,24 @@ export default defineComponent({
const acct = new URL(location.href).searchParams.get('acct');
if (acct == null) return;
/*
const dialog = os.dialog({
type: 'waiting',
text: this.$t('fetchingAsApObject') + '...',
showOkButton: false,
showCancelButton: false,
cancelableByBgClick: false
});
*/
if (acct.startsWith('https://')) {
os.api('ap/show', {
uri: acct
}).then(res => {
if (res.type == 'User') {
this.follow(res.object);
} else if (res.type === 'Note') {
this.$router.push(`/notes/${res.object.id}`);
} else {
os.dialog({
type: 'error',
Expand All @@ -42,7 +46,7 @@ export default defineComponent({
window.close();
});
}).finally(() => {
dialog.close();
//dialog.close();
});
} else {
os.api('users/show', parseAcct(acct)).then(user => {
Expand All @@ -55,7 +59,7 @@ export default defineComponent({
window.close();
});
}).finally(() => {
dialog.close();
//dialog.close();
});
}
},
Expand Down
6 changes: 4 additions & 2 deletions src/client/scripts/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,26 +48,28 @@ export async function search(q?: string | null | undefined) {
}

if (q.startsWith('https://')) {
/*
const dialog = os.dialog({
type: 'waiting',
text: i18n.global.t('fetchingAsApObject') + '...',
showOkButton: false,
showCancelButton: false,
cancelableByBgClick: false
});
*/

try {
const res = await os.api('ap/show', {
uri: q
});
dialog.cancel();
//dialog.cancel();
if (res.type === 'User') {
router.push(`/@${res.object.username}@${res.object.host}`);
} else if (res.type === 'Note') {
router.push(`/notes/${res.object.id}`);
}
} catch (e) {
dialog.cancel();
//dialog.cancel();
// TODO: Show error
}

Expand Down
2 changes: 2 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export type Source = {
};

mediaProxy?: string;

signToActivityPubGet?: boolean;
};

/**
Expand Down
5 changes: 3 additions & 2 deletions src/misc/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import fetch, { HeadersInit } from 'node-fetch';
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import config from '../config';
import { URL } from 'url';

export async function getJson(url: string, accept = 'application/json, */*', timeout = 10000, headers?: HeadersInit) {
const res = await fetch(url, {
Expand Down Expand Up @@ -69,14 +70,14 @@ const _https = new https.Agent({
* Get http proxy or non-proxy agent
*/
export const httpAgent = config.proxy
? new HttpProxyAgent(config.proxy)
? new HttpProxyAgent(config.proxy) as unknown as http.Agent
: _http;

/**
* Get https proxy or non-proxy agent
*/
export const httpsAgent = config.proxy
? new HttpsProxyAgent(config.proxy)
? new HttpsProxyAgent(config.proxy) as unknown as https.Agent
: _https;

/**
Expand Down
97 changes: 97 additions & 0 deletions src/remote/activitypub/request.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as http from 'http';
import * as https from 'https';
import { sign } from 'http-signature';
import * as crypto from 'crypto';
Expand All @@ -7,6 +8,9 @@ import { ILocalUser } from '../../models/entities/user';
import { UserKeypairs } from '../../models';
import { ensure } from '../../prelude/ensure';
import { getAgentByUrl } from '../../misc/fetch';
import { URL } from 'url';
import got from 'got';
import * as Got from 'got';

export default async (user: ILocalUser, url: string, object: any) => {
const timeout = 10 * 1000;
Expand Down Expand Up @@ -62,3 +66,96 @@ export default async (user: ILocalUser, url: string, object: any) => {
req.end(data);
});
};

/**
* Get AP object with http-signature
* @param user http-signature user
* @param url URL to fetch
*/
export async function signedGet(url: string, user: ILocalUser) {
const timeout = 10 * 1000;

const keypair = await UserKeypairs.findOne({
userId: user.id
}).then(ensure);

const req = got.get<any>(url, {
headers: {
'Accept': 'application/activity+json, application/ld+json',
'User-Agent': config.userAgent,
},
responseType: 'json',
timeout,
hooks: {
beforeRequest: [
options => {
options.request = (url: URL, opt: http.RequestOptions, callback?: (response: any) => void) => {
// Select custom agent by URL
opt.agent = getAgentByUrl(url, false);

// Wrap original https?.request
const requestFunc = url.protocol === 'http:' ? http.request : https.request;
const clientRequest = requestFunc(url, opt, callback) as http.ClientRequest;

// HTTP-Signature
sign(clientRequest, {
authorizationHeaderName: 'Signature',
key: keypair.privateKey,
keyId: `${config.url}/users/${user.id}#main-key`,
headers: ['(request-target)', 'host', 'date', 'accept']
});

return clientRequest;
};
},
],
},
retry: 0,
});

const res = await receiveResponce(req, 10 * 1024 * 1024);

return res.body;
}

/**
* Receive response (with size limit)
* @param req Request
* @param maxSize size limit
*/
export async function receiveResponce<T>(req: Got.CancelableRequest<Got.Response<T>>, maxSize: number) {
// 応答ヘッダでサイズチェック
req.on('response', (res: Got.Response) => {
const contentLength = res.headers['content-length'];
if (contentLength != null) {
const size = Number(contentLength);
if (size > maxSize) {
req.cancel();
}
}
});

// 受信中のデータでサイズチェック
req.on('downloadProgress', (progress: Got.Progress) => {
if (progress.transferred > maxSize) {
req.cancel();
}
});

// 応答取得 with ステータスコードエラーの整形
const res = await req.catch(e => {
if (e.name === 'HTTPError') {
const statusCode = (e as Got.HTTPError).response.statusCode;
const statusMessage = (e as Got.HTTPError).response.statusMessage;
throw {
name: `StatusError`,
statusCode,
message: `${statusCode} ${statusMessage}`,
};
} else {
throw e;
}
});

return res;
}
13 changes: 12 additions & 1 deletion src/remote/activitypub/resolver.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import config from '../../config';
import { getJson } from '../../misc/fetch';
import { ILocalUser } from '../../models/entities/user';
import { getInstanceActor } from '../../services/instance-actor';
import { signedGet } from './request';
import { IObject, isCollectionOrOrderedCollection, ICollection, IOrderedCollection } from './type';

export default class Resolver {
private history: Set<string>;
private user?: ILocalUser;

constructor() {
this.history = new Set();
Expand Down Expand Up @@ -39,7 +44,13 @@ export default class Resolver {

this.history.add(value);

const object = await getJson(value, 'application/activity+json, application/ld+json');
if (config.signToActivityPubGet && !this.user) {
this.user = await getInstanceActor();
}

const object = this.user
? await signedGet(value, this.user)
: await getJson(value, 'application/activity+json, application/ld+json');

if (object == null || (
Array.isArray(object['@context']) ?
Expand Down
17 changes: 17 additions & 0 deletions src/services/instance-actor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createSystemUser } from './create-system-user';
import { ILocalUser } from '../models/entities/user';
import { Users } from '../models';

const ACTOR_USERNAME = 'instance.actor' as const;

export async function getInstanceActor(): Promise<ILocalUser> {
const user = await Users.findOne({
host: null,
username: ACTOR_USERNAME
});

if (user) return user as ILocalUser;

const created = await createSystemUser(ACTOR_USERNAME);
return created as ILocalUser;
}
Loading

0 comments on commit 85a0f69

Please sign in to comment.