diff --git a/utopia-remix/app/handlers/splatLoad.ts b/utopia-remix/app/handlers/splatLoad.ts index ca6a715600e9..484f7edf91c6 100644 --- a/utopia-remix/app/handlers/splatLoad.ts +++ b/utopia-remix/app/handlers/splatLoad.ts @@ -3,7 +3,7 @@ import { ensure } from '../util/api.server' import { proxy } from '../util/proxy.server' import { Status } from '../util/statusCodes' -const allowedExtensions = [ +export const allowedAssetExtensions = [ // images '.bmp', '.gif', @@ -30,7 +30,7 @@ export async function handleSplatLoad(req: Request) { const url = new URL(req.url) const extension = path.extname(url.pathname).toLowerCase() - ensure(allowedExtensions.includes(extension), 'invalid extension', Status.BAD_REQUEST) + ensure(allowedAssetExtensions.includes(extension), 'invalid extension', Status.BAD_REQUEST) return proxy(req, { rawOutput: true }) } diff --git a/utopia-remix/app/handlers/validators.ts b/utopia-remix/app/handlers/validators.ts index 8fa5aa4ca430..2255b02a1351 100644 --- a/utopia-remix/app/handlers/validators.ts +++ b/utopia-remix/app/handlers/validators.ts @@ -26,51 +26,73 @@ export function validateProjectAccess( return validationError(new ApiError('Invalid project id', Status.BAD_REQUEST)) } - const ownership = await getProjectOwnership( - { id: projectId }, - { includeDeleted: includeDeleted }, - ) - if (ownership == null) { - return validationError(new ApiError('Project not found', Status.NOT_FOUND)) - } + return canAccessProject({ + projectId: projectId, + includeDeleted: includeDeleted, + canRequestAccess: canRequestAccess, + request: req, + permission: permission, + }) + } +} - const user = await getUser(req) - const userId = user?.user_id ?? null +export async function canAccessProject({ + projectId, + includeDeleted, + request, + permission, + canRequestAccess, +}: { + projectId: string + request: Request + permission: UserProjectPermission + includeDeleted?: boolean + canRequestAccess?: boolean +}) { + const ownership = await getProjectOwnership( + { id: projectId }, + { includeDeleted: includeDeleted ?? false }, + ) + if (ownership == null) { + return validationError(new ApiError('Project not found', Status.NOT_FOUND)) + } + + const user = await getUser(request) + const userId = user?.user_id ?? null - // the user owns the project, go on - if (userId === ownership.ownerId) { + // the user owns the project, go on + if (userId === ownership.ownerId) { + return validationOk() + } + + // if the project is collaborative (or public)… + if ( + ownership.accessLevel === AccessLevel.COLLABORATIVE || + ownership.accessLevel === AccessLevel.PUBLIC + ) { + // the user can access the project, go on + const hasProjectPermissions = await hasUserProjectPermission(projectId, userId, permission) + if (hasProjectPermissions) { return validationOk() } - // if the project is collaborative (or public)… - if ( - ownership.accessLevel === AccessLevel.COLLABORATIVE || - ownership.accessLevel === AccessLevel.PUBLIC - ) { - // the user can access the project, go on - const hasProjectPermissions = await hasUserProjectPermission(projectId, userId, permission) - if (hasProjectPermissions) { - return validationOk() - } - - // …and access can be requested… - if (canRequestAccess === true) { - // …and the user can request permissions… - const hasRequestAccessPermission = await hasUserProjectPermission( - projectId, - userId, - UserProjectPermission.CAN_REQUEST_ACCESS, - ) - if (hasRequestAccessPermission) { - // …return a 403! - return validationError(new ApiError('Forbidden', Status.FORBIDDEN)) - } + // …and access can be requested… + if (canRequestAccess === true) { + // …and the user can request permissions… + const hasRequestAccessPermission = await hasUserProjectPermission( + projectId, + userId, + UserProjectPermission.CAN_REQUEST_ACCESS, + ) + if (hasRequestAccessPermission) { + // …return a 403! + return validationError(new ApiError('Forbidden', Status.FORBIDDEN)) } } - - // the project is not available, it's conceptually a 401 but just return a 404 so we don't leak - return validationError(new ApiError('Project not found', Status.NOT_FOUND)) } + + // the project is not available, it's conceptually a 401 but just return a 404 so we don't leak + return validationError(new ApiError('Project not found', Status.NOT_FOUND)) } export const ALLOW: AccessValidator = async () => validationOk() diff --git a/utopia-remix/app/routes/$.tsx b/utopia-remix/app/routes/$.tsx new file mode 100644 index 000000000000..8cc319cd6390 --- /dev/null +++ b/utopia-remix/app/routes/$.tsx @@ -0,0 +1,34 @@ +import type { LoaderFunctionArgs } from '@remix-run/node' +import { ALLOW } from '../handlers/validators' +import { proxy } from '../util/proxy.server' +import { handle } from '../util/api.server' +import { getProxyAssetPath } from '../util/assets.server' + +export async function loader(args: LoaderFunctionArgs) { + return handle(args, { + GET: { + validator: ALLOW, + handler: handleLoadAssetFromRoot, + }, + }) +} + +/** + * Some editor routes can reference assets as if they are on the root path. + * This function will look at the incoming request and if the requested resource + * has an allowed extension it will try to derive the project ID + * from the request's referer header. + * If a match is found, and the project can be accessed, the request will be proxied to + * /p//. + */ +async function handleLoadAssetFromRoot(req: Request) { + const proxyProjectAssetPath = await getProxyAssetPath(req) + if (proxyProjectAssetPath == null) { + return {} + } + + return proxy(req, { + path: proxyProjectAssetPath, + rawOutput: true, + }) +} diff --git a/utopia-remix/app/util/assets.server.spec.ts b/utopia-remix/app/util/assets.server.spec.ts new file mode 100644 index 000000000000..bf6e21782b5a --- /dev/null +++ b/utopia-remix/app/util/assets.server.spec.ts @@ -0,0 +1,93 @@ +import { newTestRequest } from '../test-util' +import { getProjectIdFromReferer, getProxyAssetPath } from './assets.server' +import * as validators from '../handlers/validators' + +describe('assets', () => { + let validatorsMock: jest.SpyInstance + + afterEach(() => { + validatorsMock.mockRestore() + }) + beforeEach(() => { + validatorsMock = jest.spyOn(validators, 'canAccessProject') + }) + + describe('getProjectIdFromReferer', () => { + it("returns null if there's no referer", async () => { + const got = getProjectIdFromReferer(newTestRequest()) + expect(got).toBe(null) + }) + it('returns null if the origin does not match', async () => { + const got = getProjectIdFromReferer( + newTestRequest({ + headers: { + referer: 'http://foo.bar', + }, + }), + ) + expect(got).toBe(null) + }) + it("returns null if the path is not a project's one", async () => { + const got = getProjectIdFromReferer( + newTestRequest({ + headers: { + referer: 'http://localhost:8000/wrong', + }, + }), + ) + expect(got).toBe(null) + }) + it('returns null if the tokenization is not correct', async () => { + const got = getProjectIdFromReferer( + newTestRequest({ + headers: { + referer: 'http://localhost:8000/p/', + }, + }), + ) + expect(got).toBe(null) + }) + it('returns the project id', async () => { + const got = getProjectIdFromReferer( + newTestRequest({ + headers: { + referer: 'http://localhost:8000/p/foo-1-2-3/bar/baz', + }, + }), + ) + expect(got).toBe('foo') + }) + }) + describe('getProxyAssetPath', () => { + it('returns null if the extension is not allowed', async () => { + const got = await getProxyAssetPath(newTestRequest({ path: '/foo.WRONG' })) + expect(got).toBe(null) + }) + it('returns null if the project id cannot be derived', async () => { + const got = await getProxyAssetPath( + newTestRequest({ path: '/foo.png', headers: { referer: 'http://localhost:8000/p' } }), + ) + expect(got).toBe(null) + }) + it('returns null if the project cannot be accessed', async () => { + validatorsMock.mockResolvedValue({ ok: false }) + const got = await getProxyAssetPath( + newTestRequest({ + path: '/foo.png', + headers: { referer: 'http://localhost:8000/p/one' }, + }), + ) + expect(got).toBe(null) + }) + it('returns the proxied path', async () => { + validatorsMock.mockResolvedValue({ ok: true }) + const got = await getProxyAssetPath( + newTestRequest({ + path: '/foo.png', + headers: { referer: 'http://localhost:8000/p/one' }, + }), + ) + expect(got).toBe(`/p/one/foo.png`) + }) + }) +}) diff --git a/utopia-remix/app/util/assets.server.ts b/utopia-remix/app/util/assets.server.ts new file mode 100644 index 000000000000..1c8b163a0fe6 --- /dev/null +++ b/utopia-remix/app/util/assets.server.ts @@ -0,0 +1,57 @@ +import urlJoin from 'url-join' +import { ServerEnvironment } from '../env.server' +import { allowedAssetExtensions } from '../handlers/splatLoad' +import { canAccessProject } from '../handlers/validators' +import { UserProjectPermission } from '../types' + +export async function getProxyAssetPath(req: Request): Promise { + const url = new URL(req.url) + + // the extension must be allowed + if (!allowedAssetExtensions.some((extension) => url.pathname.endsWith(extension))) { + return null + } + + // the request referer must contain the project id + const maybeProjectId = getProjectIdFromReferer(req) + if (maybeProjectId == null) { + return null + } + + // validate access to the project + const { ok } = await canAccessProject({ + projectId: maybeProjectId, + permission: UserProjectPermission.CAN_VIEW_PROJECT, + request: req, + }) + if (!ok) { + return null + } + + return '/' + urlJoin('p', maybeProjectId, url.pathname) +} + +export function getProjectIdFromReferer(req: Request): string | null { + const referer = req.headers.get('referer') + if (referer == null) { + return null + } + + const refererURL = new URL(referer) + const isMaybeProjectReferer = + refererURL.origin === ServerEnvironment.CORS_ORIGIN && + (refererURL.pathname.startsWith('/p/') || refererURL.pathname.startsWith('/project/')) + if (!isMaybeProjectReferer) { + return null + } + + const maybeProjectId = refererURL.pathname + .replace(/^\/p(roject)?\//, '') + .split('/')[0] + .split('-')[0] + if (maybeProjectId.length === 0) { + return null + } + + return maybeProjectId +}