Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/cli/config/firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
57 changes: 53 additions & 4 deletions packages/feeds/src/firebase/proxy-github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -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',
Expand All @@ -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;
}
Expand Down Expand Up @@ -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());
Expand Down
13 changes: 9 additions & 4 deletions packages/feeds/src/firebase/serve-feeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
198 changes: 198 additions & 0 deletions packages/feeds/tests/proxy-github.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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<string, any> = {}) => {
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<string, string>,
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();
});
});
Loading
Loading