diff --git a/packages/app-desktop/gui/ShareNoteDialog.tsx b/packages/app-desktop/gui/ShareNoteDialog.tsx index f5dffc743e..7c6c4aa38a 100644 --- a/packages/app-desktop/gui/ShareNoteDialog.tsx +++ b/packages/app-desktop/gui/ShareNoteDialog.tsx @@ -95,7 +95,7 @@ export function ShareNoteDialog(props: Props) { const copyLinksToClipboard = (shares: StateShare[]) => { const links = []; - for (const share of shares) links.push(ShareService.instance().shareUrl(share)); + for (const share of shares) links.push(ShareService.instance().shareUrl(ShareService.instance().userId, share)); clipboard.writeText(links.join('\n')); }; diff --git a/packages/app-desktop/runForSharing.sh b/packages/app-desktop/runForSharing.sh index ec10c74e5c..6ea7f7a5a2 100755 --- a/packages/app-desktop/runForSharing.sh +++ b/packages/app-desktop/runForSharing.sh @@ -25,13 +25,13 @@ if [ "$RESET_ALL" == "1" ]; then rm -rf "$PROFILE_DIR" echo "config keychain.supported 0" >> "$CMD_FILE" - echo "config sync.target 9" >> "$CMD_FILE" - echo "config sync.9.path http://api-joplincloud.local:22300" >> "$CMD_FILE" - echo "config sync.9.username $USER_EMAIL" >> "$CMD_FILE" - echo "config sync.9.password 123456" >> "$CMD_FILE" + echo "config sync.target 10" >> "$CMD_FILE" + echo "config sync.10.path http://api.joplincloud.local:22300" >> "$CMD_FILE" + echo "config sync.10.username $USER_EMAIL" >> "$CMD_FILE" + echo "config sync.10.password 123456" >> "$CMD_FILE" if [ "$USER_NUM" == "1" ]; then - curl --data '{"action": "createTestUsers"}' -H 'Content-Type: application/json' http://api-joplincloud.local:22300/api/debug + curl --data '{"action": "createTestUsers"}' -H 'Content-Type: application/json' http://api.joplincloud.local:22300/api/debug echo 'mkbook "shared"' >> "$CMD_FILE" echo 'mkbook "other"' >> "$CMD_FILE" diff --git a/packages/lib/BaseApplication.ts b/packages/lib/BaseApplication.ts index 9e517ff750..4247413d5e 100644 --- a/packages/lib/BaseApplication.ts +++ b/packages/lib/BaseApplication.ts @@ -768,7 +768,7 @@ export default class BaseApplication { if (Setting.value('env') === Env.Dev) { Setting.setValue('sync.10.path', 'https://api.joplincloud.com'); Setting.setValue('sync.10.userContentPath', 'https://joplinusercontent.com'); - // Setting.setValue('sync.10.path', 'http://api-joplincloud.local:22300'); + // Setting.setValue('sync.10.path', 'http://api.joplincloud.local:22300'); // Setting.setValue('sync.10.userContentPath', 'http://joplinusercontent.local:22300'); } diff --git a/packages/lib/JoplinServerApi.ts b/packages/lib/JoplinServerApi.ts index 7df852fb23..334b36e2cb 100644 --- a/packages/lib/JoplinServerApi.ts +++ b/packages/lib/JoplinServerApi.ts @@ -56,8 +56,14 @@ export default class JoplinServerApi { return rtrimSlashes(this.options_.baseUrl()); } - public userContentBaseUrl() { - return this.options_.userContentBaseUrl() || this.baseUrl(); + public userContentBaseUrl(userId: string) { + if (this.options_.userContentBaseUrl()) { + if (!userId) throw new Error('User ID must be specified'); + const url = new URL(this.options_.userContentBaseUrl()); + return `${url.protocol}//${userId.substr(0, 10).toLowerCase()}.${url.host}`; + } else { + return this.baseUrl(); + } } private async session() { diff --git a/packages/lib/services/share/ShareService.ts b/packages/lib/services/share/ShareService.ts index c4f8aeb1ee..2c48cef8b5 100644 --- a/packages/lib/services/share/ShareService.ts +++ b/packages/lib/services/share/ShareService.ts @@ -33,6 +33,10 @@ export default class ShareService { return this.store.getState()[stateRootKey] as State; } + public get userId(): string { + return this.api() ? this.api().userId : ''; + } + private api(): JoplinServerApi { if (this.api_) return this.api_; @@ -136,8 +140,8 @@ export default class ShareService { await Note.save({ id: note.id, is_shared: 0 }); } - public shareUrl(share: StateShare): string { - return `${this.api().userContentBaseUrl()}/shares/${share.id}`; + public shareUrl(userId: string, share: StateShare): string { + return `${this.api().userContentBaseUrl(userId)}/shares/${share.id}`; } public get shares() { diff --git a/packages/lib/testing/test-utils.ts b/packages/lib/testing/test-utils.ts index 41e2f0ba9c..6073a05e84 100644 --- a/packages/lib/testing/test-utils.ts +++ b/packages/lib/testing/test-utils.ts @@ -577,7 +577,7 @@ async function initFileApi() { // const joplinServerAuth = { // "email": "admin@localhost", // "password": "admin", - // "baseUrl": "http://api-joplincloud.local:22300", + // "baseUrl": "http://api.joplincloud.local:22300", // "userContentBaseUrl": "" // } diff --git a/packages/server/src/models/ShareModel.ts b/packages/server/src/models/ShareModel.ts index 8f26d937e8..cb313f5638 100644 --- a/packages/server/src/models/ShareModel.ts +++ b/packages/server/src/models/ShareModel.ts @@ -5,6 +5,7 @@ import { unique } from '../utils/array'; import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from '../utils/errors'; import { setQueryParameters } from '../utils/urlUtils'; import BaseModel, { AclAction, DeleteOptions, ValidateOptions } from './BaseModel'; +import { userIdFromUserContentUrl } from '../utils/routeUtils'; export default class ShareModel extends BaseModel { @@ -33,6 +34,19 @@ export default class ShareModel extends BaseModel { } } + public checkShareUrl(share: Share, shareUrl: string) { + if (this.baseUrl === this.userContentUrl) return; // OK + + const userId = userIdFromUserContentUrl(shareUrl); + const shareUserId = share.owner_id.toLowerCase(); + + if (userId.length >= 10 && shareUserId.indexOf(userId) === 0) { + // OK + } else { + throw new ErrorBadRequest('Invalid origin (User Content)'); + } + } + protected objectToApiOutput(object: Share): Share { const output: Share = {}; diff --git a/packages/server/src/routes/index/shares.ts b/packages/server/src/routes/index/shares.ts index 9000c78e0f..55499f1976 100644 --- a/packages/server/src/routes/index/shares.ts +++ b/packages/server/src/routes/index/shares.ts @@ -36,6 +36,8 @@ router.get('shares/:id', async (path: SubPath, ctx: AppContext) => { const result = await renderItem(ctx, item, share); + ctx.models.share().checkShareUrl(share, ctx.URL.origin); + ctx.response.body = result.body; ctx.response.set('Content-Type', result.mime); ctx.response.set('Content-Length', result.size.toString()); diff --git a/packages/server/src/utils/routeUtils.ts b/packages/server/src/utils/routeUtils.ts index 385a0d14ff..32472e1fa7 100644 --- a/packages/server/src/utils/routeUtils.ts +++ b/packages/server/src/utils/routeUtils.ts @@ -1,5 +1,5 @@ import { baseUrl } from '../config'; -import { Item, ItemAddressingType } from '../db'; +import { Item, ItemAddressingType, Uuid } from '../db'; import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from './errors'; import Router from './Router'; import { AppContext, HttpMethod, RouteType } from './types'; @@ -153,19 +153,26 @@ export function parseSubPath(basePath: string, p: string, rawPath: string = null return output; } -export function isValidOrigin(requestOrigin: string, endPointBaseUrl: string): boolean { +export function isValidOrigin(requestOrigin: string, endPointBaseUrl: string, routeType: RouteType): boolean { const host1 = (new URL(requestOrigin)).host; const host2 = (new URL(endPointBaseUrl)).host; - return host1 === host2; + + if (routeType === RouteType.UserContent) { + if (host1 === host2) return true; + + const hostNoPrefix = host1.split('.').slice(1).join('.'); + return hostNoPrefix === host2; + } else { + return host1 === host2; + } +} + +export function userIdFromUserContentUrl(url: string): Uuid { + const s = (new URL(url)).hostname.split('.'); + return s[0].toLowerCase(); } export function routeResponseFormat(context: AppContext): RouteResponseFormat { - // const rawPath = context.path; - // if (match && match.route.responseFormat) return match.route.responseFormat; - - // let path = rawPath; - // if (match) path = match.basePath ? match.basePath : match.subPath.raw; - const path = context.path; return path.indexOf('api') === 0 || path.indexOf('/api') === 0 ? RouteResponseFormat.Json : RouteResponseFormat.Html; } @@ -175,7 +182,7 @@ export async function execRequest(routes: Routers, ctx: AppContext) { if (!match) throw new ErrorNotFound(); const endPoint = match.route.findEndPoint(ctx.request.method as HttpMethod, match.subPath.schema); - if (ctx.URL && !isValidOrigin(ctx.URL.origin, baseUrl(endPoint.type))) throw new ErrorNotFound('Invalid origin', 'invalidOrigin'); + if (ctx.URL && !isValidOrigin(ctx.URL.origin, baseUrl(endPoint.type), endPoint.type)) throw new ErrorNotFound('Invalid origin', 'invalidOrigin'); // This is a generic catch-all for all private end points - if we // couldn't get a valid session, we exit now. Individual end points