Compare commits

...

9 Commits

Author SHA1 Message Date
Johann150 7c89e99243
fix registry migration
It can happen that registry items were created at exactly the same time for some reason.
2023-02-11 12:52:28 +01:00
Johann150 6ed13ea9a7
fix typo, the 2nd 2023-02-11 10:23:15 +01:00
Chloe Kudryavtsev 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
Johann150 27b912b9b0
security: check schema for URL previews
Changelog: Fixed
2023-02-10 20:06:18 +01:00
Johann150 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
Johann150 c1ae134c0a
security: make sure there is no SQL insertion 2023-02-10 18:31:23 +01:00
Johann150 3ad6323c23
fix registry migration
closes FoundKeyGang/FoundKey#337
2023-02-05 20:37:06 +01:00
15 changed files with 100 additions and 44 deletions

View File

@ -5,8 +5,8 @@ export class registryRemoveDomain1675375940759 {
await queryRunner.query(`DROP INDEX "public"."IDX_0a72bdfcdb97c0eca11fe7ecad"`);
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`);
// 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")`);
// delete existing duplicated entries, keeping the latest created one
await queryRunner.query(`DELETE FROM "registry_item" AS "a" WHERE "id" != (SELECT MAX("id") 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")`);
}

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) {
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 fileName = emoji.name + (ext ? '.' + ext : '');
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) {
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 emojiPath = outputPath + '/' + record.fileName;
await Emojis.delete({

View File

@ -34,21 +34,38 @@ export function getApIds(value: ApObject | undefined): string[] {
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
*/
export function getApId(value: string | Object): string {
if (typeof value === 'string') return value;
if (typeof value.id === 'string') return value.id;
throw new Error('cannot detemine id');
let url = null;
if (typeof value === 'string') url = value;
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');
}
export function getOneApHrefNullable(value: ApObject | undefined): string | undefined {
const firstOne = Array.isArray(value) ? value[0] : value;
return getApHrefNullable(firstOne);
export function getApHrefNullable(value: string | IObject | undefined): string | undefined {
let url = null;
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 {
if (typeof value === 'string') return value;
if (typeof value?.href === 'string') return value.href;
return undefined;
export function getOneApHrefNullable(value: ApObject | undefined): string | undefined {
if (!value) {
return;
} 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 {

View File

@ -3,7 +3,6 @@ import { MINUTE, HOUR } from '@/const.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { Notes } from '@/models/index.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 define from '../../define.js';
@ -122,7 +121,7 @@ export default define(meta, paramDef, async () => {
for (let i = 0; i < range; i++) {
countPromises.push(Promise.all(hots.map(tag => Notes.createQueryBuilder('note')
.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 > :gt', { gt: new Date(now.getTime() - (interval * (i + 1))) })
.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')
.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) })
.cache(60000 * 60) // 60 min
.getRawOne()

View File

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

View File

@ -38,6 +38,14 @@ export const urlPreviewHandler = async (ctx: Koa.Context): Promise<void> => {
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.thumbnail = wrap(summary.thumbnail);
@ -54,12 +62,10 @@ export const urlPreviewHandler = async (ctx: Koa.Context): Promise<void> => {
};
function wrap(url?: string): string | null {
return url != null
? url.match(/^https?:\/\//)
? `${config.url}/proxy/preview.webp?${query({
url,
preview: '1',
})}`
: url
: null;
if (url == null) return null;
if (!['http:', 'https:'].includes(new URL(url).protocol)) return null;
return config.url + '/proxy/preview.webp?' + query({
url,
preview: '1',
});
}

View File

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

View File

@ -54,6 +54,7 @@ let player = $ref({
let playerEnabled = $ref(false);
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)')) {
requestUrl.hostname = 'www.youtube.com';
@ -72,7 +73,9 @@ fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).the
icon = info.icon;
sitename = info.sitename;
fetching = false;
player = info.player;
if (['http:', 'https:'].includes(new URL(info.player.url).protocol)) {
player = info.player;
}
});
});
</script>

View File

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

View File

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

View File

@ -24,7 +24,11 @@ export function createAiScriptEnv(opts) {
return confirm.canceled ? values.FALSE : values.TRUE;
}),
'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++;
if (apiRequests > 16) return values.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 => {
os.inputText({
title: q,
}).then(({ result: a }) => {
}).then(({ canceled, result: a }) => {
if (canceled) return;
ok(a);
});
});

View File

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