Compare commits

...

8 commits

Author SHA1 Message Date
5d6cceda49 Merge branch 'main' into mk.absturztau.be 2023-02-11 08:27:24 +01:00
6a40ef3569 fix typo
tfw no building before push
2023-02-10 20:35:09 -05:00
syuilo
09fe55379e
client: check input for aiscript
af1c9251fc
5f3640c7fd

Co-authored-by: Johann150 <johann.galle@protonmail.com>
Changelog: Fixed
2023-02-10 20:06:31 +01:00
27b912b9b0
security: check schema for URL previews
Changelog: Fixed
2023-02-10 20:06:18 +01:00
48fd543d0f
security: check URL schema of AP URIs
Changelog: Fixed
2023-02-10 20:06:12 +01:00
syuilo
af272ce358
fix(server): validate filename and emoji name to improve security
0d7256678e

Co-authored-by: Johann150 <johann.galle@protonmail.com>
Changelog: Fixed
2023-02-10 20:05:53 +01:00
c1ae134c0a
security: make sure there is no SQL insertion 2023-02-10 18:31:23 +01:00
3ad6323c23
fix registry migration
closes FoundKeyGang/FoundKey#337
2023-02-05 20:37:06 +01:00
15 changed files with 99 additions and 43 deletions

View file

@ -6,7 +6,7 @@ export class registryRemoveDomain1675375940759 {
await queryRunner.query(`ALTER TABLE "registry_item" DROP COLUMN "domain"`); await queryRunner.query(`ALTER TABLE "registry_item" DROP COLUMN "domain"`);
await queryRunner.query(`ALTER TABLE "registry_item" ALTER COLUMN "key" TYPE text USING "key"::text`); await queryRunner.query(`ALTER TABLE "registry_item" ALTER COLUMN "key" TYPE text USING "key"::text`);
// delete existing duplicated entries, keeping the latest updated one // delete existing duplicated entries, keeping the latest updated one
await queryRunner.query(`DELETE FROM "registry_item" AS "a" WHERE "updatedAt" != (SELECT MAX("updatedAt") OVER (PARTITION BY "userId", "key", "scope") FROM "registry_item" AS "b" WHERE "a"."userId" = "b"."userId" AND "a"."key" = "b"."key" AND "a"."scope" = "b"."scope")`); await queryRunner.query(`DELETE FROM "registry_item" AS "a" WHERE "updatedAt" != (SELECT MAX("updatedAt") FROM "registry_item" AS "b" WHERE "a"."userId" = "b"."userId" AND "a"."key" = "b"."key" AND "a"."scope" = "b"."scope" GROUP BY "userId", "key", "scope")`);
await queryRunner.query(`ALTER TABLE "registry_item" ADD CONSTRAINT "UQ_b8d6509f847331273ab99daccc7" UNIQUE ("userId", "key", "scope")`); await queryRunner.query(`ALTER TABLE "registry_item" ADD CONSTRAINT "UQ_b8d6509f847331273ab99daccc7" UNIQUE ("userId", "key", "scope")`);
} }

View file

@ -1,3 +0,0 @@
export function safeForSql(text: string): boolean {
return !/[\0\x08\x09\x1a\n\r"'\\\%]/g.test(text);
}

View file

@ -58,6 +58,10 @@ export async function exportCustomEmojis(job: Bull.Job, done: () => void): Promi
}); });
for (const emoji of customEmojis) { for (const emoji of customEmojis) {
if (!/^[a-zA-Z0-9_]+$/.test(emoji.name)) {
this.logger.error(`invalid emoji name: ${emoji.name}, skipping in emoji export`);
continue;
}
const ext = mime.extension(emoji.type); const ext = mime.extension(emoji.type);
const fileName = emoji.name + (ext ? '.' + ext : ''); const fileName = emoji.name + (ext ? '.' + ext : '');
const emojiPath = path + '/' + fileName; const emojiPath = path + '/' + fileName;

View file

@ -50,6 +50,10 @@ export async function importCustomEmojis(job: Bull.Job<DbUserImportJobData>, don
for (const record of meta.emojis) { for (const record of meta.emojis) {
if (!record.downloaded) continue; if (!record.downloaded) continue;
if (!/^[a-zA-Z0-9_]+?([a-zA-Z0-9\.]+)?$/.test(record.fileName)) {
this.logger.error(`invalid filename: ${record.fileName}, skipping in emoji import`);
continue;
}
const emojiInfo = record.emoji; const emojiInfo = record.emoji;
const emojiPath = outputPath + '/' + record.fileName; const emojiPath = outputPath + '/' + record.fileName;
await Emojis.delete({ await Emojis.delete({

View file

@ -34,21 +34,38 @@ export function getApIds(value: ApObject | undefined): string[] {
return array.map(x => getApId(x)); return array.map(x => getApId(x));
} }
/**
* Get first ActivityStreams Object id
*/
export function getOneApId(value: ApObject): string {
const firstOne = Array.isArray(value) ? value[0] : value;
return getApId(firstOne);
}
/** /**
* Get ActivityStreams Object id * Get ActivityStreams Object id
*/ */
export function getApId(value: string | Object): string { export function getApId(value: string | Object): string {
if (typeof value === 'string') return value; let url = null;
if (typeof value.id === 'string') return value.id; if (typeof value === 'string') url = value;
throw new Error('cannot detemine id'); else if (typeof value.id === 'string') url = value.id;
if (!url || !['https:', 'http:'].includes(new URL(url).protocol)) {
throw new Error('cannot determine id');
} else {
return url;
}
}
/**
* Get first (valid) ActivityStreams Object id
*/
export function getOneApId(value: ApObject): string {
if (Array.isArray(value)) {
// find the first valid ID
for (const id of value) {
try {
return getApId(x);
} catch {
continue;
}
}
throw new Error('cannot determine id');
} else {
return getApId(value);
}
} }
/** /**
@ -60,15 +77,34 @@ export function getApType(value: Object): string {
throw new Error('cannot detect type'); throw new Error('cannot detect type');
} }
export function getOneApHrefNullable(value: ApObject | undefined): string | undefined { export function getApHrefNullable(value: string | IObject | undefined): string | undefined {
const firstOne = Array.isArray(value) ? value[0] : value; let url = null;
return getApHrefNullable(firstOne); if (typeof value === 'string') url = value;
else if (typeof value?.href === 'string') url = value.href;
if (!url || !['https:', 'http:'].includes(new URL(url).protocol)) {
return undefined;
} else {
return url;
}
} }
export function getApHrefNullable(value: string | IObject | undefined): string | undefined { export function getOneApHrefNullable(value: ApObject | undefined): string | undefined {
if (typeof value === 'string') return value; if (!value) {
if (typeof value?.href === 'string') return value.href; return;
return undefined; } else if (Array.isArray(value)) {
// find the first valid href
for (const href of value) {
try {
return getApHrefNullable(href);
} catch {
continue;
}
}
return undefined;
} else {
return getApHrefNullable(value);
}
} }
export interface IActivity extends IObject { export interface IActivity extends IObject {

View file

@ -3,7 +3,6 @@ import { MINUTE, HOUR } from '@/const.js';
import { fetchMeta } from '@/misc/fetch-meta.js'; import { fetchMeta } from '@/misc/fetch-meta.js';
import { Notes } from '@/models/index.js'; import { Notes } from '@/models/index.js';
import { Note } from '@/models/entities/note.js'; import { Note } from '@/models/entities/note.js';
import { safeForSql } from '@/misc/safe-for-sql.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import define from '../../define.js'; import define from '../../define.js';
@ -122,7 +121,7 @@ export default define(meta, paramDef, async () => {
for (let i = 0; i < range; i++) { for (let i = 0; i < range; i++) {
countPromises.push(Promise.all(hots.map(tag => Notes.createQueryBuilder('note') countPromises.push(Promise.all(hots.map(tag => Notes.createQueryBuilder('note')
.select('count(distinct note.userId)') .select('count(distinct note.userId)')
.where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`) .where(':tag = ANY(note.tags)', { tag })
.andWhere('note.createdAt < :lt', { lt: new Date(now.getTime() - (interval * i)) }) .andWhere('note.createdAt < :lt', { lt: new Date(now.getTime() - (interval * i)) })
.andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - (interval * (i + 1))) }) .andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - (interval * (i + 1))) })
.cache(60000) // 1 min .cache(60000) // 1 min
@ -136,7 +135,7 @@ export default define(meta, paramDef, async () => {
const totalCounts = await Promise.all(hots.map(tag => Notes.createQueryBuilder('note') const totalCounts = await Promise.all(hots.map(tag => Notes.createQueryBuilder('note')
.select('count(distinct note.userId)') .select('count(distinct note.userId)')
.where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`) .where(':tag = ANY(note.tags)', { tag })
.andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - rangeA) }) .andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - rangeA) })
.cache(60000 * 60) // 60 min .cache(60000 * 60) // 60 min
.getRawOne() .getRawOne()

View file

@ -1,6 +1,5 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Notes } from '@/models/index.js'; import { Notes } from '@/models/index.js';
import { safeForSql } from '@/misc/safe-for-sql.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import define from '../../define.js'; import define from '../../define.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js'; import { makePaginationQuery } from '../../common/make-pagination-query.js';
@ -86,15 +85,14 @@ export default define(meta, paramDef, async (ps, me) => {
try { try {
if (ps.tag) { if (ps.tag) {
if (!safeForSql(ps.tag)) throw new Error('Injection'); query.andWhere(':tag = ANY(note.tags)', { tag: normalizeForSearch(ps.tag) });
query.andWhere(`'{"${normalizeForSearch(ps.tag)}"}' <@ note.tags`);
} else { } else {
let i = 0;
query.andWhere(new Brackets(qb => { query.andWhere(new Brackets(qb => {
for (const tags of ps.query!) { for (const tags of ps.query!) {
qb.orWhere(new Brackets(qb => { qb.orWhere(new Brackets(qb => {
for (const tag of tags) { for (const tag of tags) {
if (!safeForSql(tag)) throw new Error('Injection'); qb.andWhere(`:tag${++i} = ANY(note.tags)`, { ['tag' + i]: normalizeForSearch(tag) });
qb.andWhere(`'{"${normalizeForSearch(tag)}"}' <@ note.tags`);
} }
})); }));
} }

View file

@ -38,6 +38,14 @@ export const urlPreviewHandler = async (ctx: Koa.Context): Promise<void> => {
logger.succ(`Got preview of ${url}: ${summary.title}`); logger.succ(`Got preview of ${url}: ${summary.title}`);
if (summary.url && !(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) {
throw new Error('unsupported schema included');
}
if (summary.player?.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) {
throw new Error('unsupported schema included');
}
summary.icon = wrap(summary.icon); summary.icon = wrap(summary.icon);
summary.thumbnail = wrap(summary.thumbnail); summary.thumbnail = wrap(summary.thumbnail);
@ -54,12 +62,10 @@ export const urlPreviewHandler = async (ctx: Koa.Context): Promise<void> => {
}; };
function wrap(url?: string): string | null { function wrap(url?: string): string | null {
return url != null if (url == null) return null;
? url.match(/^https?:\/\//) if (!['http:', 'https:'].includes(new URL(url).protocol)) return null;
? `${config.url}/proxy/preview.webp?${query({ return config.url + '/proxy/preview.webp?' + query({
url, url,
preview: '1', preview: '1',
})}` });
: url
: null;
} }

View file

@ -35,6 +35,7 @@ const props = withDefaults(defineProps<{
const self = props.url.startsWith(local); const self = props.url.startsWith(local);
const uri = new URL(props.url); const uri = new URL(props.url);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
let el: HTMLElement | null = $ref(null); let el: HTMLElement | null = $ref(null);
let schema = $ref(uri.protocol); let schema = $ref(uri.protocol);

View file

@ -54,6 +54,7 @@ let player = $ref({
let playerEnabled = $ref(false); let playerEnabled = $ref(false);
const requestUrl = new URL(props.url); const requestUrl = new URL(props.url);
if(!['http:', 'https:'].includes(requestUrl.protocol)) throw new Error('invalid url');
if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) { if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
requestUrl.hostname = 'www.youtube.com'; requestUrl.hostname = 'www.youtube.com';
@ -72,7 +73,9 @@ fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).the
icon = info.icon; icon = info.icon;
sitename = info.sitename; sitename = info.sitename;
fetching = false; fetching = false;
player = info.player; if (['http:', 'https:'].includes(new URL(info.player.url).protocol)) {
player = info.player;
}
}); });
}); });
</script> </script>

View file

@ -59,6 +59,7 @@ async function run() {
os.inputText({ os.inputText({
title: q, title: q,
}).then(({ canceled, result: a }) => { }).then(({ canceled, result: a }) => {
if (canceled) return;
ok(a); ok(a);
}); });
}); });

View file

@ -18,7 +18,8 @@ export function install(plugin) {
return new Promise(ok => { return new Promise(ok => {
inputText({ inputText({
title: q, title: q,
}).then(({ result: a }) => { }).then(({ canceled, result: a }) => {
if (canceled) return;
ok(a); ok(a);
}); });
}); });

View file

@ -24,7 +24,11 @@ export function createAiScriptEnv(opts) {
return confirm.canceled ? values.FALSE : values.TRUE; return confirm.canceled ? values.FALSE : values.TRUE;
}), }),
'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => { 'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => {
if (token) utils.assertString(token); if (token) {
utils.assertString(token);
// In case there is a bug, it could be undefined.
if (typeof token.value !== 'string') throw new Error('invalid token');
}
apiRequests++; apiRequests++;
if (apiRequests > 16) return values.NULL; if (apiRequests > 16) return values.NULL;
const res = await os.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token || null)); const res = await os.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token || null));

View file

@ -72,7 +72,8 @@ const run = async (): Promise<void> => {
return new Promise(ok => { return new Promise(ok => {
os.inputText({ os.inputText({
title: q, title: q,
}).then(({ result: a }) => { }).then(({ canceled, result: a }) => {
if (canceled) return;
ok(a); ok(a);
}); });
}); });

View file

@ -60,7 +60,8 @@ const run = async (): Promise<void> => {
return new Promise(ok => { return new Promise(ok => {
os.inputText({ os.inputText({
title: q, title: q,
}).then(({ result: a }) => { }).then(({ canceled, result: a }) => {
if (canceled) return;
ok(a); ok(a);
}); });
}); });