From 33da4e2647677029c45266c8838b7759e03bcce4 Mon Sep 17 00:00:00 2001 From: Vitor Date: Mon, 24 Aug 2026 16:21:51 -0300 Subject: [PATCH 1/6] feat(feeds): Enable CMS sign in through the store's own GitHub proxy Decap CMS could never actually work through this proxy, it only lacked consumers to surface the defects: - Answer `/user` with the authenticated store user, so the CMS editor is identified as the commit author instead of the token owner - Report `permissions.push` on the bare repository endpoint: write access is granted by `edit_storefront` on store auth, not by the `GITHUB_TOKEN` owner - Accept the `token` auth scheme Decap sends (a fixed 7 char offset for "Bearer " was dropping one character of the store access token) - Keep the query string when proxying (`?ref={branch}` was lost, so reads always resolved against the default branch) - Send the actual byte count on Content-Length (default commit messages contain curly quotes, so requests with them were rejected by fetch) - Forward `/search/**`, used by Decap for entry notes - Serve the proxy before and regardless of catalog fetching, so the CMS does not go down nor wait when the products API is slow or failing Co-Authored-By: Claude Opus 5 --- packages/feeds/src/firebase/proxy-github.ts | 27 ++++++++++++++++++--- packages/feeds/src/firebase/serve-feeds.ts | 11 ++++++--- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/feeds/src/firebase/proxy-github.ts b/packages/feeds/src/firebase/proxy-github.ts index 85128615a..54268b315 100644 --- a/packages/feeds/src/firebase/proxy-github.ts +++ b/packages/feeds/src/firebase/proxy-github.ts @@ -5,6 +5,7 @@ import { logger } from '@cloudcommerce/firebase/lib/config'; const proxyGithubApi = async (req: Request, res: Response) => { const { GITHUB_REPO, GITHUB_TOKEN } = process.env; + const isUserEndpoint = req.path.endsWith('/user'); if (!GITHUB_REPO && req.path.includes('/repos/_/')) { res.status(403).send('Missing GitHub repository name'); return; @@ -24,7 +25,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 +37,16 @@ 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, + avatar_url: null, + }); + return; + } } catch (_err: any) { const error = _err as ApiError; if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) { @@ -46,10 +58,12 @@ 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 isRepoEndpoint = /\/repos\/[^/]+\/[^/]+$/.test(url.split('?')[0]); res.set('X-Proxy-URL', url); const headers = { Accept: 'application/vnd.github+json', @@ -61,7 +75,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 +109,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..3fb81848d 100644 --- a/packages/feeds/src/firebase/serve-feeds.ts +++ b/packages/feeds/src/firebase/serve-feeds.ts @@ -77,6 +77,13 @@ const fetching = new Promise((resolve, reject) => { }); 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 +121,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; From db49b36fe50823683bf17396db93659a3d278568 Mon Sep 17 00:00:00 2001 From: Vitor Date: Mon, 24 Aug 2026 16:21:51 -0300 Subject: [PATCH 2/6] fix(cli): Route CMS Git endpoints to the feeds function on hosting The GitHub proxy rewrite required a `{git,contents,issues,branches,pulls, commits}` segment, so `/_api/user`, `/_api/repos/{owner}/{repo}`, bare `/pulls` (Decap lists and creates PRs there) and `/_api/search/**` fell through to SSR. Replaced by `/_api/repos/**` plus the auth endpoints. Co-Authored-By: Claude Opus 5 --- packages/cli/config/firebase.json | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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" }, From b8b004c81e04fc5bc57e86323189e9f298664da0 Mon Sep 17 00:00:00 2001 From: Vitor Date: Mon, 24 Aug 2026 16:21:51 -0300 Subject: [PATCH 3/6] fix(storefront): Keep the CMS usable when the platform GitHub token expires The /admin page trusted the `gh_token` returned by the platform without ever checking it, and had no fallback. An expired credential surfaced as Decap's misleading "Repo not found" error, with no way to edit content until someone reissued the token on the platform side. - Probe the credential on `GET /user` before trusting it - Fall back to the store own GitHub proxy (`/_api`, `feeds` function) with the SSO token, probing the repo endpoint since it checks store auth, `GITHUB_TOKEN` and repository access at once - Show an explicit message when no backend can authenticate, instead of letting Decap blame the repository Co-Authored-By: Claude Opus 5 --- .../storefront/src/lib/scripts/decap-cms.ts | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/storefront/src/lib/scripts/decap-cms.ts b/packages/storefront/src/lib/scripts/decap-cms.ts index 32b7c3775..1192b78d6 100644 --- a/packages/storefront/src/lib/scripts/decap-cms.ts +++ b/packages/storefront/src/lib/scripts/decap-cms.ts @@ -32,6 +32,25 @@ 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}` }, + }); + return res.ok; + } catch { + return false; + } +}; + +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 +79,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 +139,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 From 89e00e7fc4e03c0e0ea1b5e740f0df4153644471 Mon Sep 17 00:00:00 2001 From: Vitor Date: Mon, 24 Aug 2026 21:31:43 -0300 Subject: [PATCH 4/6] fix(feeds): Restrict the GitHub proxy to the store repo and CMS endpoints Review follow-ups on the proxy: - Pin proxied requests to `GITHUB_REPO` and allowlist CMS subresources (`git`, `contents`, `issues`, `branches`, `pulls`, `commits`), in the function itself since hosting rewrites are bypassable via the direct function URL. Without this, any store user with `edit_storefront` could reach `/hooks`, `/keys` or arbitrary repositories through the PAT - Restore CORS and preflight handling on proxy responses (regression on 85bdcae74), now answered by the proxy itself - `Cache-Control: private, no-store` on authenticated responses - Guard the module level catalog promise against unhandled rejection now that proxy requests skip awaiting it Covered by unit tests exercising the requests Decap CMS actually sends. Co-Authored-By: Claude Opus 5 --- packages/feeds/src/firebase/proxy-github.ts | 30 ++- packages/feeds/src/firebase/serve-feeds.ts | 2 + packages/feeds/tests/proxy-github.test.ts | 197 ++++++++++++++++++++ 3 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 packages/feeds/tests/proxy-github.test.ts diff --git a/packages/feeds/src/firebase/proxy-github.ts b/packages/feeds/src/firebase/proxy-github.ts index 54268b315..b1b7241f2 100644 --- a/packages/feeds/src/firebase/proxy-github.ts +++ b/packages/feeds/src/firebase/proxy-github.ts @@ -5,6 +5,18 @@ 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; + } + // 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'); @@ -43,7 +55,6 @@ const proxyGithubApi = async (req: Request, res: Response) => { login: authUser.username, name: authUser.name || authUser.username, email: authUser.email, - avatar_url: null, }); return; } @@ -63,7 +74,22 @@ const proxyGithubApi = async (req: Request, res: Response) => { + (req.originalUrl || req.url) .replace(/^.+?(?=\/(repos|search)\/)/, '') .replace('/repos/_/', `/repos/${GITHUB_REPO}/`); - const isRepoEndpoint = /\/repos\/[^/]+\/[^/]+$/.test(url.split('?')[0]); + 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', diff --git a/packages/feeds/src/firebase/serve-feeds.ts b/packages/feeds/src/firebase/serve-feeds.ts index 3fb81848d..7ab9a6cde 100644 --- a/packages/feeds/src/firebase/serve-feeds.ts +++ b/packages/feeds/src/firebase/serve-feeds.ts @@ -75,6 +75,8 @@ 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/') diff --git a/packages/feeds/tests/proxy-github.test.ts b/packages/feeds/tests/proxy-github.test.ts new file mode 100644 index 000000000..56a2b27a0 --- /dev/null +++ b/packages/feeds/tests/proxy-github.test.ts @@ -0,0 +1,197 @@ +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(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(); + }); +}); From 8e4600892af22dc56c732a78b0cf516f4b7b6515 Mon Sep 17 00:00:00 2001 From: Vitor Date: Mon, 24 Aug 2026 21:31:43 -0300 Subject: [PATCH 5/6] fix(storefront): Only drop the CMS credential on explicit auth failure A transient network error on the backend probe was discarding a working token, downgrading a healthy CMS session to the error screen. Now only 401 and 403 invalidate; probes also get a shorter 5s timeout so the failure path stays responsive. Co-Authored-By: Claude Opus 5 --- packages/storefront/src/lib/scripts/decap-cms.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/storefront/src/lib/scripts/decap-cms.ts b/packages/storefront/src/lib/scripts/decap-cms.ts index 1192b78d6..84f317db5 100644 --- a/packages/storefront/src/lib/scripts/decap-cms.ts +++ b/packages/storefront/src/lib/scripts/decap-cms.ts @@ -37,10 +37,12 @@ const checkGitBackend = async (url: string, token: string) => { // Same auth scheme Decap uses, `token` keyword is not configurable const res = await afetch(url, { headers: { Authorization: `token ${token}` }, - }); - return res.ok; + }, 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 false; + return true; } }; From fccf5f77888d6737b914080bd2f88f49efb274a3 Mon Sep 17 00:00:00 2001 From: Vitor Date: Mon, 24 Aug 2026 22:42:33 -0300 Subject: [PATCH 6/6] fix(feeds): Keep security headers on proxied Git responses Proxy requests now skip the serve-feeds path that set CSP, nosniff and X-Frame-Options, so the proxy sets them itself. Co-Authored-By: Claude Opus 5 --- packages/feeds/src/firebase/proxy-github.ts | 4 ++++ packages/feeds/tests/proxy-github.test.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/feeds/src/firebase/proxy-github.ts b/packages/feeds/src/firebase/proxy-github.ts index b1b7241f2..bbbfe6ff0 100644 --- a/packages/feeds/src/firebase/proxy-github.ts +++ b/packages/feeds/src/firebase/proxy-github.ts @@ -15,6 +15,10 @@ const proxyGithubApi = async (req: Request, res: Response) => { 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'); diff --git a/packages/feeds/tests/proxy-github.test.ts b/packages/feeds/tests/proxy-github.test.ts index 56a2b27a0..2c3a34393 100644 --- a/packages/feeds/tests/proxy-github.test.ts +++ b/packages/feeds/tests/proxy-github.test.ts @@ -102,6 +102,7 @@ describe('/user stub', () => { 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(); }); });