diff --git a/packages/cli/config/firebase.json b/packages/cli/config/firebase.json index 5b2241777..3a0f4469e 100644 --- a/packages/cli/config/firebase.json +++ b/packages/cli/config/firebase.json @@ -72,7 +72,22 @@ "region": "us-east4" }, { - "source": "/_api/repos/**/{git,contents,issues,branches,pulls,commits}/**", + "source": "/_api/repos/*/*", + "function": "feeds", + "region": "us-east4" + }, + { + "source": "/_api/repos/**", + "function": "feeds", + "region": "us-east4" + }, + { + "source": "/_api/user", + "function": "feeds", + "region": "us-east4" + }, + { + "source": "/_api/search/**", "function": "feeds", "region": "us-east4" }, diff --git a/packages/feeds/src/firebase/proxy-github.ts b/packages/feeds/src/firebase/proxy-github.ts index 85128615a..bbbfe6ff0 100644 --- a/packages/feeds/src/firebase/proxy-github.ts +++ b/packages/feeds/src/firebase/proxy-github.ts @@ -5,6 +5,23 @@ import { logger } from '@cloudcommerce/firebase/lib/config'; const proxyGithubApi = async (req: Request, res: Response) => { const { GITHUB_REPO, GITHUB_TOKEN } = process.env; + if (!process.env.FEEDS_DISABLE_CORS) { + res.set('Access-Control-Allow-Origin', '*'); + res.set('Access-Control-Allow-Methods', '*'); + res.set('Access-Control-Max-Age', '600'); + res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type'); + } + if (req.method === 'OPTIONS') { + res.end(); + return; + } + // Same security headers `serve-feeds` sets, proxy requests skip that path + res.set('Content-Security-Policy', "default-src 'self'"); + res.set('X-Content-Type-Options', 'nosniff'); + res.set('X-Frame-Options', 'DENY'); + // Authenticated content behind the CDN must never be cached + res.set('Cache-Control', 'private, no-store'); + const isUserEndpoint = req.path.endsWith('/user'); if (!GITHUB_REPO && req.path.includes('/repos/_/')) { res.status(403).send('Missing GitHub repository name'); return; @@ -24,7 +41,8 @@ const proxyGithubApi = async (req: Request, res: Response) => { res.sendStatus(406); return; } - const accessToken = req.get('Authorization')?.slice(7); // "Bearer ***" + // Decap CMS sends "token ***", other clients "Bearer ***" + const accessToken = req.get('Authorization')?.replace(/^(Bearer|token)\s+/i, ''); if (!accessToken) { res.status(401).send('Access token is required on Authorization header'); return; @@ -35,6 +53,15 @@ const proxyGithubApi = async (req: Request, res: Response) => { res.status(401).send('Your auth user does not have permission to edit storefront'); return; } + if (isUserEndpoint) { + // Git backends (Decap CMS) expect a GitHub-like user, authenticated store user is the author + res.send({ + login: authUser.username, + name: authUser.name || authUser.username, + email: authUser.email, + }); + return; + } } catch (_err: any) { const error = _err as ApiError; if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) { @@ -46,10 +73,27 @@ const proxyGithubApi = async (req: Request, res: Response) => { logger.error(error); return; } + // `req.url` keeps the query string, `req.path` does not (`?ref={branch}` and alike) const url = 'https://api.github.com' - + req.path - .replace(/^.+\/repos\//, '/repos/') + + (req.originalUrl || req.url) + .replace(/^.+?(?=\/(repos|search)\/)/, '') .replace('/repos/_/', `/repos/${GITHUB_REPO}/`); + const { pathname } = new URL(url); + let isPathAllowed = false; + if (pathname.startsWith('/repos/')) { + const [owner, repo, subresource] = pathname.slice('/repos/'.length).split('/'); + // The PAT may reach more than the store repo, never proxy beyond it + const isRepoPinned = !GITHUB_REPO || `${owner}/${repo}` === GITHUB_REPO; + isPathAllowed = isRepoPinned && (!subresource + || ['git', 'contents', 'issues', 'branches', 'pulls', 'commits'].includes(subresource)); + } else if (pathname === '/search/issues') { + isPathAllowed = !GITHUB_REPO || `${req.query.q || ''}`.includes(`repo:${GITHUB_REPO}`); + } + if (!isPathAllowed) { + res.status(403).send('Endpoint not allowed through this proxy'); + return; + } + const isRepoEndpoint = /\/repos\/[^/]+\/[^/]+$/.test(pathname); res.set('X-Proxy-URL', url); const headers = { Accept: 'application/vnd.github+json', @@ -61,7 +105,8 @@ const proxyGithubApi = async (req: Request, res: Response) => { if (typeof req.body === 'object') { body = JSON.stringify(req.body); headers['Content-Type'] = 'application/json'; - headers['Content-Length'] = body.length.toString(); + // Commit messages and content may be multibyte, `body.length` is not the byte count + headers['Content-Length'] = Buffer.byteLength(body).toString(); } else { body = req.body; } @@ -94,6 +139,10 @@ const proxyGithubApi = async (req: Request, res: Response) => { // } if (json !== undefined) { + if (isRepoEndpoint && response.ok && json && typeof json === 'object') { + // Write access is granted by store auth (`edit_storefront`), not by the token owner + json.permissions = { ...json.permissions, pull: true, push: true }; + } res.send(json); } else { res.send(await response.text()); diff --git a/packages/feeds/src/firebase/serve-feeds.ts b/packages/feeds/src/firebase/serve-feeds.ts index ae5df08c6..7ab9a6cde 100644 --- a/packages/feeds/src/firebase/serve-feeds.ts +++ b/packages/feeds/src/firebase/serve-feeds.ts @@ -75,8 +75,17 @@ const fetchAllProducts = async () => { const fetching = new Promise((resolve, reject) => { fetchAllProducts().then(resolve).catch(reject); }); +// Git proxy requests skip awaiting `fetching`, its rejection must not go unhandled +fetching.catch(() => null); const serveFeeds = async (req: Request, res: Response) => { + if (req.path.includes('/repos/') + || req.path.includes('/search/') + || req.path.endsWith('/user')) { + // CMS Git proxy must not wait for nor depend on catalog fetching + await proxyGithubApi(req, res); + return; + } try { await fetching; } catch (err) { @@ -114,10 +123,6 @@ const serveFeeds = async (req: Request, res: Response) => { res.redirect(302, '/sitemap-catalog.xml'); break; default: - if (req.path.includes('/repos/')) { - await proxyGithubApi(req, res); - break; - } if (req.path.endsWith('/catalog.xml')) { await renderCatalog(req, res, products); break; diff --git a/packages/feeds/tests/proxy-github.test.ts b/packages/feeds/tests/proxy-github.test.ts new file mode 100644 index 000000000..2c3a34393 --- /dev/null +++ b/packages/feeds/tests/proxy-github.test.ts @@ -0,0 +1,198 @@ +import { + describe, test, expect, vi, beforeEach, +} from 'vitest'; + +vi.mock('@cloudcommerce/firebase/lib/config', () => ({ + logger: { info: () => {}, error: () => {}, warn: () => {} }, +})); +vi.mock('@cloudcommerce/api', () => ({ default: { get: vi.fn() } })); + +import api from '@cloudcommerce/api'; +import proxyGithubApi from '../src/firebase/proxy-github'; + +const apiGet = api.get as ReturnType; + +const AUTH_USER = { + username: 'marketing1', + name: 'Marketing', + email: 'mkt@example.com', + edit_storefront: true, +}; + +const makeReq = (path: string, { + method = 'GET', + auth = 'token store-access-token-123', + body, + query = {}, +}: Record = {}) => { + const [pathname, search] = path.split('?'); + const searchParams = new URLSearchParams(search || ''); + searchParams.forEach((value, key) => { query[key] = value; }); + return { + path: pathname, + originalUrl: path, + url: path, + method, + query, + body, + get: (header: string) => (header === 'Authorization' ? auth : undefined), + } as any; +}; + +const makeRes = () => { + const res: any = { + headers: {} as Record, + statusCode: 200, + body: undefined, + ended: false, + }; + res.set = (k: string, v: string) => { res.headers[k] = v; return res; }; + res.status = (code: number) => { res.statusCode = code; return res; }; + res.send = (body: any) => { res.body = body; res.ended = true; return res; }; + res.sendStatus = (code: number) => { res.statusCode = code; res.ended = true; return res; }; + res.end = () => { res.ended = true; return res; }; + return res; +}; + +const ghFetch = vi.fn(); + +beforeEach(() => { + process.env.GITHUB_REPO = 'tiasonia/tiasonia'; + process.env.GITHUB_TOKEN = 'ghp_test'; + delete process.env.FEEDS_DISABLE_CORS; + apiGet.mockReset().mockResolvedValue({ data: AUTH_USER }); + ghFetch.mockReset().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ default_branch: 'main' }), + }); + vi.stubGlobal('fetch', ghFetch); +}); + +describe('CORS and OPTIONS', () => { + test('answers preflight without auth nor upstream call', async () => { + const res = makeRes(); + await proxyGithubApi(makeReq('/_api/repos/tiasonia/tiasonia', { method: 'OPTIONS', auth: undefined }), res); + expect(res.ended).toBe(true); + expect(res.headers['Access-Control-Allow-Headers']).toContain('Authorization'); + expect(apiGet).not.toHaveBeenCalled(); + expect(ghFetch).not.toHaveBeenCalled(); + }); +}); + +describe('authentication', () => { + test.each(['token abc123', 'Bearer abc123'])('accepts "%s" scheme', async (auth) => { + const res = makeRes(); + await proxyGithubApi(makeReq('/_api/user', { auth }), res); + expect(apiGet).toHaveBeenCalledWith('authentications/me', { accessToken: 'abc123' }); + }); + + test('rejects store user without edit_storefront', async () => { + apiGet.mockResolvedValue({ data: { ...AUTH_USER, edit_storefront: false } }); + const res = makeRes(); + await proxyGithubApi(makeReq('/_api/repos/tiasonia/tiasonia'), res); + expect(res.statusCode).toBe(401); + expect(ghFetch).not.toHaveBeenCalled(); + }); +}); + +describe('/user stub', () => { + test('answers with the store user as a GitHub-like user', async () => { + const res = makeRes(); + await proxyGithubApi(makeReq('/_api/user'), res); + expect(res.body).toMatchObject({ login: 'marketing1', name: 'Marketing' }); + expect(res.headers['Cache-Control']).toBe('private, no-store'); + expect(res.headers['X-Content-Type-Options']).toBe('nosniff'); + expect(ghFetch).not.toHaveBeenCalled(); + }); +}); + +describe('path ACL', () => { + test('pins the repository to GITHUB_REPO', async () => { + const res = makeRes(); + await proxyGithubApi(makeReq('/_api/repos/evil/evil/contents/x'), res); + expect(res.statusCode).toBe(403); + expect(ghFetch).not.toHaveBeenCalled(); + }); + + test.each(['hooks', 'keys', 'collaborators', 'actions'])( + 'rejects %s subresource', + async (sub) => { + const res = makeRes(); + await proxyGithubApi(makeReq(`/_api/repos/tiasonia/tiasonia/${sub}`), res); + expect(res.statusCode).toBe(403); + }, + ); + + test.each([ + '/_api/repos/tiasonia/tiasonia', + '/_api/repos/tiasonia/tiasonia/contents/content/home.json', + '/_api/repos/tiasonia/tiasonia/git/refs/heads/main', + '/_api/repos/tiasonia/tiasonia/pulls', + '/_api/repos/tiasonia/tiasonia/branches/main', + ])('allows %s', async (path) => { + const res = makeRes(); + await proxyGithubApi(makeReq(path), res); + expect(res.statusCode).toBe(200); + expect(ghFetch).toHaveBeenCalled(); + }); + + test('search allowed only scoped to the store repo', async () => { + let res = makeRes(); + await proxyGithubApi(makeReq('/_api/search/issues?q=repo%3Atiasonia%2Ftiasonia+label%3Ax'), res); + expect(res.statusCode).toBe(200); + res = makeRes(); + await proxyGithubApi(makeReq('/_api/search/issues?q=repo%3Aevil%2Fevil'), res); + expect(res.statusCode).toBe(403); + }); +}); + +describe('proxied request building', () => { + test('keeps the query string (?ref={branch})', async () => { + const res = makeRes(); + await proxyGithubApi( + makeReq('/_api/repos/tiasonia/tiasonia/contents/content/home.json?ref=cms%2Fhome'), + res, + ); + const url = ghFetch.mock.calls[0][0]; + expect(url).toBe('https://api.github.com/repos/tiasonia/tiasonia/contents/content/home.json?ref=cms%2Fhome'); + }); + + test('sends the byte count on Content-Length for multibyte bodies', async () => { + const body = { message: 'Update home “página inicial”', content: 'eyJhIjoxfQ==' }; + const res = makeRes(); + await proxyGithubApi( + makeReq('/_api/repos/tiasonia/tiasonia/contents/content/home.json', { method: 'PUT', body }), + res, + ); + const { headers } = ghFetch.mock.calls[0][1]; + const raw = JSON.stringify(body); + expect(Number(headers['Content-Length'])).toBe(Buffer.byteLength(raw)); + expect(Number(headers['Content-Length'])).toBeGreaterThan(raw.length); + }); +}); + +describe('bare repository endpoint', () => { + test('reports push permission granted by store auth', async () => { + ghFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ full_name: 'tiasonia/tiasonia', permissions: { admin: false, push: false } }), + }); + const res = makeRes(); + await proxyGithubApi(makeReq('/_api/repos/tiasonia/tiasonia'), res); + expect(res.body.permissions).toMatchObject({ push: true, pull: true }); + }); + + test('does not inject permissions on error responses', async () => { + ghFetch.mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ message: 'Not Found' }), + }); + const res = makeRes(); + await proxyGithubApi(makeReq('/_api/repos/tiasonia/tiasonia'), res); + expect(res.statusCode).toBe(404); + expect(res.body.permissions).toBeUndefined(); + }); +}); diff --git a/packages/storefront/src/lib/scripts/decap-cms.ts b/packages/storefront/src/lib/scripts/decap-cms.ts index 32b7c3775..84f317db5 100644 --- a/packages/storefront/src/lib/scripts/decap-cms.ts +++ b/packages/storefront/src/lib/scripts/decap-cms.ts @@ -32,6 +32,27 @@ const initCmsWithPreview = () => { } }; +const checkGitBackend = async (url: string, token: string) => { + try { + // Same auth scheme Decap uses, `token` keyword is not configurable + const res = await afetch(url, { + headers: { Authorization: `token ${token}` }, + }, 5000); + // Only an explicit auth failure invalidates the backend, + // transient errors must not kill a working session + return res.status !== 401 && res.status !== 403; + } catch { + return true; + } +}; + +const showAuthError = (message: string) => { + const el = document.createElement('div'); + el.style.cssText = 'padding:2rem;font:1rem/1.5 system-ui,sans-serif;max-width:40rem;margin:auto'; + el.innerHTML = `

CMS indisponível

${message}

`; + document.body.replaceChildren(el); +}; + const authAndInitCms = async (storeData: CmsStoreData) => { const { location, @@ -60,7 +81,8 @@ const authAndInitCms = async (storeData: CmsStoreData) => { let repository: string | null = null; const searchParams = new URLSearchParams(location.search); const ssoToken = searchParams.get('sso_token') || searchParams.get('access_token'); - if (!cmsConfig.backend?.base_url) { + const isManagedAuth = !cmsConfig.backend?.base_url; + if (isManagedAuth) { if (!token && !ssoToken && CMS_SSO_URL) { const url = new URL(CMS_SSO_URL); url.searchParams.set('sso_store_id', `${ECOM_STORE_ID}`); @@ -119,15 +141,37 @@ const authAndInitCms = async (storeData: CmsStoreData) => { } } } + if (token) { + const isTokenValid = await checkGitBackend('https://api.github.com/user', token); + if (!isTokenValid) { + // Platform credential is dead, store own proxy may still be able to commit + token = null; + } + } + if (repository) { + cmsConfig.backend.repo = repository; + } if (token) { delete cmsConfig.backend.api_root; cmsConfig.backend.name = 'github'; - if (repository) { - cmsConfig.backend.repo = repository; + } else if (ssoToken && cmsConfig.backend.repo !== '_owner/_name') { + // Proxied by the store own `feeds` function with its `GITHUB_TOKEN`, + // probing the repo endpoint checks store auth, PAT and repo access at once + const apiRoot = `${location.origin}/_api`; + const probeUrl = `${apiRoot}/repos/${cmsConfig.backend.repo}`; + if (await checkGitBackend(probeUrl, ssoToken)) { + token = ssoToken; + cmsConfig.backend.api_root = apiRoot; } } } } + if (!token && isManagedAuth) { + showAuthError('Não foi possível autenticar no repositório da loja. A credencial' + + ' do GitHub está expirada ou ausente, reconecte o repositório da loja ou' + + ' contate o suporte.'); + return; + } if (token) { // Ref.: https://github.com/decaporg/decap-cms/blob/e93c94f1ce707719dfb7750af82b17c38b461831/packages/decap-cms-lib-auth/src/netlify-auth.js#L46 // E.g.: https://github.com/Herohtar/netlify-cms-oauth-firebase/blob/master/functions/index.js#L9-L25