fix: enforce private feedback access across list and detail
Deploy EG-BIM QA Gateway / deploy (push) Successful in 50s
Deploy EG-BIM QA Gateway / deploy (push) Successful in 50s
This commit is contained in:
1 parent
e2e70c13ac
commit
a5a5f24f92
10 files changed
+379
-24
No files matched your search
@@ -209,4 +209,44 @@ try {
|
||||
await context.close();
|
||||
}
|
||||
|
||||
for (const scenario of ['private-upstream-leak', 'private-upstream-denied']) {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const commentRequests = [];
|
||||
const secret = {id:id(1),requester_id:OWNER,is_secret:true,title:'PRIVATE_TITLE',contents:'PRIVATE_BODY',attachments:[{url:'/fixture/PRIVATE_FILE'}]};
|
||||
await context.addInitScript(({secret}) => {
|
||||
localStorage.setItem('baron.qa.posts.egbim',JSON.stringify([{...secret,secret:true,content:'PRIVATE_CACHED_BODY'}]));
|
||||
localStorage.setItem('baron.qa.comments.egbim',JSON.stringify({[secret.id]:[{content:'PRIVATE_CACHED_COMMENT'}]}));
|
||||
},{secret});
|
||||
await page.route('**/*',async route => {
|
||||
const url = new URL(route.request().url());
|
||||
if(url.origin!=='https://qa.example.test') return route.abort();
|
||||
const send = (body,status=200) => route.fulfill({status,contentType:'application/json',body:JSON.stringify(body)});
|
||||
if(url.pathname==='/auth/session') return send({authenticated:true,user:{requesterId:'another-user',name:'동명이인'}});
|
||||
if(url.pathname==='/api/feedbacks') return send({items:[secret],meta:{total:1}});
|
||||
if(url.pathname.endsWith('/comments')) {commentRequests.push(url.pathname);return send({comments:[{content:'PRIVATE_COMMENT'}]});}
|
||||
if(url.pathname==='/api/feedbacks/'+id(1)) return scenario==='private-upstream-denied' ? send({error:'private_feedback_forbidden'},403) : send({feedback:secret});
|
||||
try {
|
||||
const path=url.pathname.endsWith('/')?url.pathname+'index.html':url.pathname;
|
||||
const contentType=path.endsWith('.css')?'text/css':path.endsWith('.js')?'application/javascript':path.endsWith('.html')?'text/html':'application/octet-stream';
|
||||
return route.fulfill({contentType,body:await readFile(root+path)});
|
||||
} catch {return route.abort();}
|
||||
});
|
||||
await page.goto('https://qa.example.test/egbim/');
|
||||
await page.waitForFunction(()=>document.querySelector('#qaRows').textContent.includes('비밀글입니다.'));
|
||||
assert.doesNotMatch(await page.locator('#qaRows').textContent(),/PRIVATE_/);
|
||||
assert.equal(await page.locator('#qaRows tr a').count(),0);
|
||||
await page.locator('#qaRows tr').click();
|
||||
assert.equal(await page.locator('#detailModal').isVisible(),false);
|
||||
await page.goto('https://qa.example.test/egbim/?view=detail&id='+id(1));
|
||||
await page.waitForFunction(()=>document.querySelector('[data-detail-title]')?.textContent==='비밀글입니다.');
|
||||
assert.doesNotMatch(await page.locator('#detailModal').textContent(),/PRIVATE_/);
|
||||
assert.equal(await page.locator('#commentForm').isVisible(),false);
|
||||
assert.equal(await page.locator('#editButton').isVisible(),false);
|
||||
assert.equal(await page.locator('#deleteButton').isVisible(),false);
|
||||
assert.equal(await page.locator('[data-detail-attachments] a').count(),0);
|
||||
assert.deepEqual(commentRequests,[]);
|
||||
console.log(JSON.stringify({scenario,result:'PASS'}));
|
||||
await context.close();
|
||||
}
|
||||
}finally{await browser.close();}
|
||||
@@ -0,0 +1,132 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import worker from '../src/index.js';
|
||||
|
||||
const POST = '01900000-0000-7000-8000-000000000001';
|
||||
const COMMENT = '01900000-0000-7000-8000-000000000002';
|
||||
const OWNER = '9951229e-eea5-4376-96f4-78989bc3a506';
|
||||
const OTHER = 'ee2f39ac-fe52-4cfb-b4e3-4ae1d114c916';
|
||||
const env = { SESSION_COOKIE_NAME: 'fixture', SESSION_SECRET: 'test-session-key', ABC_API_KEY: 'fixture-api-key', ABC_PROJECT_ID: 'project', ABC_CHANNEL_ID: 'channel' };
|
||||
const feedback = (extra = {}) => ({ id: POST, requester_id: OWNER, is_secret: true,
|
||||
title: 'PRIVATE_TITLE', contents: 'PRIVATE_CONTENT', requester_email: 'PRIVATE_EMAIL',
|
||||
attachments: [{ url: 'https://example.test/PRIVATE_ATTACHMENT' }],
|
||||
comments: [{ content: 'PRIVATE_COMMENT' }], ...extra });
|
||||
const response = (body, status = 200) => Response.json(body, { status });
|
||||
function request(path = '', method = 'GET', viewer = OTHER, body) {
|
||||
const user = { requesterId: viewer, userUuid: OWNER, ssoSubject: OWNER, requesterTenantId: 'shared-tenant', name: 'same-name', email: 'PRIVATE_EMAIL', roles: ['ADMIN', 'SUPPORT'] };
|
||||
const payload = Buffer.from(JSON.stringify({ user, expiresAt: Date.now() + 60000 })).toString('base64url');
|
||||
const signature = createHmac('sha256', env.SESSION_SECRET).update(payload).digest('base64url');
|
||||
return new Request('https://qa.example.test/api/feedbacks' + path, { method,
|
||||
headers: { cookie: `${env.SESSION_COOKIE_NAME}=${payload}.${signature}`, 'content-type': 'application/json', 'x-requester-id': OWNER },
|
||||
body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
}
|
||||
async function upstream(handler, run) {
|
||||
const original = globalThis.fetch;
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, init) => { calls.push({ url: String(url), ...init }); return handler(url, init); };
|
||||
try { await run(calls); } finally { globalThis.fetch = original; }
|
||||
}
|
||||
|
||||
test('list masks private data before sending it to a different signed-in user and keeps pagination', async () => {
|
||||
await upstream(() => response({ items: [feedback(), feedback({ id: COMMENT, is_secret: false })], meta: { total: 102, totalPages: 2, page: 1 } }), async calls => {
|
||||
const result = await worker.fetch(request(), env);
|
||||
const { items, meta } = await result.json();
|
||||
assert.equal(result.headers.get('cache-control'), 'no-store');
|
||||
assert.equal(items[0].title, '비밀글입니다.');
|
||||
assert.equal(items[0].access_restricted, true);
|
||||
assert.doesNotMatch(JSON.stringify(items[0]), /PRIVATE_/);
|
||||
assert.equal(items[1].contents, 'PRIVATE_CONTENT');
|
||||
assert.deepEqual(meta, { total: 102, totalPages: 2, page: 1 });
|
||||
assert.equal(calls[0].headers['x-requester-id'], OTHER);
|
||||
});
|
||||
});
|
||||
|
||||
for (const flag of [true, 'true', 1, '1', 'TRUE', 'yes']) {
|
||||
for (const wrap of [value => value, value => ({ data: value }), value => ({ feedback: value })]) {
|
||||
test(`private detail refuses non-owner even when upstream returns 200 (${String(flag)}, ${wrap.toString()})`, async () => {
|
||||
await upstream(() => response(wrap(feedback({ is_secret: flag }))), async () => {
|
||||
const result = await worker.fetch(request('/' + POST), env);
|
||||
assert.equal(result.status, 403);
|
||||
assert.doesNotMatch(await result.text(), /PRIVATE_/);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const fields of [
|
||||
{ fields: { is_secret: 'true', requester_id: OWNER } },
|
||||
{ extra_fields: { is_secret: true, requester_id: OWNER } },
|
||||
{ metadata: { isSecret: true, requesterId: OWNER } },
|
||||
{ fields: [{ key: 'is_secret', value: 'true' }, { key: 'requester_id', value: OWNER }] },
|
||||
{ fields: [{ key: 'is_secret', value: 'false' }, { key: 'is_secret', value: 'true' }] },
|
||||
{ is_secret: false, metadata: { is_secret: true } },
|
||||
{ is_secret: 'unexpected-value' }
|
||||
]) {
|
||||
test(`nested and conflicting privacy metadata is protected: ${JSON.stringify(fields)}`, async () => {
|
||||
const record = { id: POST, title: 'PRIVATE_TITLE', contents: 'PRIVATE_CONTENT', ...fields };
|
||||
await upstream(() => response(record), async () => {
|
||||
const result = await worker.fetch(request('/' + POST), env);
|
||||
assert.equal(result.status, 403);
|
||||
assert.doesNotMatch(await result.text(), /PRIVATE_/);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('private records with missing author ID are denied; the owner still receives their own record', async () => {
|
||||
for (const ownerId of ['', OWNER.toUpperCase()]) {
|
||||
await upstream(() => response(feedback({ requester_id: ownerId })), async () => {
|
||||
const result = await worker.fetch(request('/' + POST, 'GET', OWNER), env);
|
||||
assert.equal(result.status, ownerId ? 200 : 403);
|
||||
if (ownerId) assert.equal((await result.json()).feedback.contents, 'PRIVATE_CONTENT');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for (const [path, method, body] of [
|
||||
['', 'PATCH', { title: 'attempt' }], ['', 'DELETE'],
|
||||
['/comments', 'GET'], ['/comments', 'POST', { content: 'attempt' }],
|
||||
['/comments/' + COMMENT, 'PATCH', { content: 'attempt' }], ['/comments/' + COMMENT, 'DELETE'],
|
||||
['/completion-confirmation', 'POST']
|
||||
]) {
|
||||
test(`private parent blocks direct ${method} ${path || 'detail'} before comments or mutations reach upstream`, async () => {
|
||||
await upstream(() => response(feedback()), async calls => {
|
||||
const result = await worker.fetch(request('/' + POST + path, method, OTHER, body), env);
|
||||
assert.equal(result.status, 403);
|
||||
assert.doesNotMatch(await result.text(), /PRIVATE_/);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.ok(calls[0].url.endsWith('/feedbacks/' + POST));
|
||||
assert.equal(calls[0].method, 'GET');
|
||||
assert.equal(calls[0].headers['x-requester-id'], OTHER);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('comment access fails closed when the parent cannot be loaded', async () => {
|
||||
for (const status of [403, 404, 503]) {
|
||||
await upstream(() => response({ message: 'PRIVATE_ERROR' }, status), async calls => {
|
||||
const result = await worker.fetch(request('/' + POST + '/comments'), env);
|
||||
assert.equal(result.status, status === 503 ? 502 : status);
|
||||
assert.doesNotMatch(await result.text(), /PRIVATE_/);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('admin-key search cannot reveal private words via matching locked records', async () => {
|
||||
await upstream(() => assert.fail('private content must not be queried through the admin search API'), async () => {
|
||||
const result = await worker.fetch(request('?searchText=PRIVATE_CONTENT&fieldKey=contents'), env);
|
||||
assert.equal(result.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
for (const isSecret of [false, true]) {
|
||||
test(`${isSecret ? 'own private' : 'public'} feedback still permits reading comments`, async () => {
|
||||
await upstream((url) => response(String(url).includes('/comments') ? { comments: [{ content: 'allowed' }] } : feedback({ is_secret: isSecret })), async calls => {
|
||||
const result = await worker.fetch(request('/' + POST + '/comments', 'GET', isSecret ? OWNER : OTHER), env);
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal((await result.json()).comments[0].content, 'allowed');
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -21,15 +21,16 @@ function element() {
|
||||
};
|
||||
}
|
||||
|
||||
async function detailPage({ comments = [], cachedComments = [], createResponse = {}, patchResponse = {}, authRefresh = false, user = USER, postOwner = USER.requesterId } = {}) {
|
||||
async function detailPage({ comments = [], cachedComments = [], cachedPosts = [], createResponse = {}, patchResponse = {}, authRefresh = false, user = USER, postOwner = USER.requesterId, isSecret = false, detailStatus = 200, commentsStatus = 200 } = {}) {
|
||||
const elements = new Map([
|
||||
'#detail', '[data-detail-category]', '[data-detail-title]', '[data-detail-status]',
|
||||
'[data-detail-date]', '[data-detail-author]', '[data-detail-company]',
|
||||
'[data-detail-department]', '[data-detail-content]', '[data-detail-secret]',
|
||||
'#comments', '#commentCount', '#commentForm', '#commentContent',
|
||||
'#commentError', '#commentSubmit', '[data-comment-edit-input]', '#editButton', '#deleteButton'
|
||||
'#commentError', '#commentSubmit', '[data-comment-edit-input]', '#editButton', '#deleteButton',
|
||||
'.comment-box', '[data-detail-attachments]'
|
||||
].map((selector) => [selector, element()]));
|
||||
const storage = new Map([[COMMENTS_KEY, JSON.stringify({ [POST]: cachedComments })]]);
|
||||
const storage = new Map([[COMMENTS_KEY, JSON.stringify({ [POST]: cachedComments })], ['baron.qa.posts.egbim', JSON.stringify(cachedPosts)]]);
|
||||
const requests = [];
|
||||
const context = vm.createContext({
|
||||
window: { QA_CONFIG: {}, location: { origin: 'https://qa.example.test', search: '?id=' + POST }, confirm: () => true },
|
||||
@@ -45,8 +46,8 @@ async function detailPage({ comments = [], cachedComments = [], createResponse =
|
||||
else if (method === 'PATCH') body = patchResponse;
|
||||
else if (method === 'DELETE') body = { ok: true };
|
||||
else if (path.endsWith('/comments')) body = { comments };
|
||||
else body = { id: POST, title: '문의 제목', contents: '문의 내용', requester_id: postOwner, requester_name: USER.name };
|
||||
return new Response(JSON.stringify(body));
|
||||
else body = { id: POST, title: '문의 제목', contents: '문의 내용', requester_id: postOwner, requester_name: USER.name, is_secret: isSecret };
|
||||
return new Response(JSON.stringify(body), { status: method === 'GET' ? path.endsWith('/comments') ? commentsStatus : detailStatus : 200 });
|
||||
}
|
||||
});
|
||||
vm.runInContext(source.replace(/\}\)\(\);\s*$/, `
|
||||
@@ -142,6 +143,36 @@ test('a cached comment without an ID remains read-only when loading the page', a
|
||||
assert.equal(page.requests.some((request) => ['PATCH', 'DELETE'].includes(request.method)), false);
|
||||
});
|
||||
|
||||
for (const status of [401, 403, 404, 500]) {
|
||||
test(`detail HTTP ${status} never falls back to a previous account's saved post or comments`, async () => {
|
||||
const page = await detailPage({ detailStatus: status, cachedPosts: [{ id: POST, title: 'CACHED_TITLE', content: 'CACHED_BODY', secret: true }], cachedComments: [comment({ content: 'CACHED_COMMENT' })] });
|
||||
assert.doesNotMatch(page.elements.get('[data-detail-title]').textContent, /CACHED_/);
|
||||
assert.doesNotMatch(page.elements.get('[data-detail-content]').textContent, /CACHED_/);
|
||||
assert.doesNotMatch(page.html(), /CACHED_/);
|
||||
assert.equal(page.elements.get('#commentForm').hidden, true);
|
||||
assert.equal(page.elements.get('#editButton').hidden, true);
|
||||
assert.equal(page.elements.get('#deleteButton').hidden, true);
|
||||
assert.equal(page.requests.some(request => request.path.endsWith('/comments')), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('a non-owner private detail stays hidden even if the API incorrectly returns the whole post', async () => {
|
||||
const page = await detailPage({ postOwner: 'another-user', isSecret: true, cachedComments: [comment()] });
|
||||
assert.equal(page.elements.get('[data-detail-title]').textContent, '비밀글입니다.');
|
||||
assert.doesNotMatch(page.elements.get('[data-detail-content]').textContent, /문의 내용/);
|
||||
assert.equal(page.elements.get('#commentForm').hidden, true);
|
||||
assert.equal(page.elements.get('[data-detail-attachments]').hidden, true);
|
||||
assert.equal(page.requests.some(request => request.path.endsWith('/comments')), false);
|
||||
});
|
||||
|
||||
test('comment errors never restore cached comments, and own private comments are not persisted', async () => {
|
||||
const denied = await detailPage({ commentsStatus: 403, cachedComments: [comment({ content: 'CACHED_COMMENT' })] });
|
||||
assert.doesNotMatch(denied.html(), /CACHED_COMMENT/);
|
||||
const own = await detailPage({ isSecret: true, comments: [comment()] });
|
||||
assert.match(own.html(), /원래 댓글/);
|
||||
assert.equal(own.stored(), undefined);
|
||||
});
|
||||
|
||||
test('another author post hides both post buttons while the viewer own comment keeps its actions', async () => {
|
||||
const page = await detailPage({ postOwner: 'another-user', comments: [comment(), comment({ id: SECOND, author_id: 'another-user' })] });
|
||||
assert.equal(page.elements.get('#editButton').hidden, true);
|
||||
|
||||
@@ -100,6 +100,22 @@ test('one and two character queries require a literal match to limit unrelated r
|
||||
assert.deepEqual(ids(page.search(posts, '오류')), []);
|
||||
});
|
||||
|
||||
test('list hides another author private title and cannot search their hidden title or contents', async () => {
|
||||
const page = pageContext(async () => ({ items: [
|
||||
record('private-other', '숨긴제목', { is_secret: true, requester_id: 'other', contents: '숨긴본문' }),
|
||||
record('private-own', '본인비밀글', { is_secret: true }),
|
||||
record('public', '공개글')
|
||||
] }));
|
||||
await page.init();
|
||||
assert.match(page.html(), /비밀글입니다\./);
|
||||
assert.match(page.html(), /본인비밀글/);
|
||||
assert.doesNotMatch(page.html(), /숨긴제목|숨긴본문|detail.html\?id=private-other/);
|
||||
page.submit('숨긴제목');
|
||||
assert.equal(page.elements.get('#resultCount').textContent, '총 0건');
|
||||
page.submit('숨긴본문');
|
||||
assert.equal(page.elements.get('#resultCount').textContent, '총 0건');
|
||||
});
|
||||
|
||||
test('all remote pages are loaded and duplicate IDs do not duplicate search results', async () => {
|
||||
const first = Array.from({ length: 100 }, (_, index) => record(String(index + 1), '일반 문의 ' + index));
|
||||
const page = pageContext(async (number) => ({ items: number === 1 ? first : [first[99], record('older', '로그인 오류')], meta: { total: 101 } }));
|
||||
|
||||
@@ -8,7 +8,8 @@ const config :Workerd.Config = (
|
||||
modules = [
|
||||
(name = "test.mjs", esModule = embed "organization-test.mjs"),
|
||||
(name = "index.js", esModule = embed "../../src/index.js"),
|
||||
(name = "org-context.js", esModule = embed "../../src/org-context.js")
|
||||
(name = "org-context.js", esModule = embed "../../src/org-context.js"),
|
||||
(name = "feedback-access.js", esModule = embed "../../src/feedback-access.js")
|
||||
]
|
||||
)),
|
||||
(name = "organization-upstream", worker = (
|
||||
|
||||
@@ -198,6 +198,7 @@ test('feedback update keeps verified owner headers and cannot overwrite organiza
|
||||
assert.ok(!String(url).includes('org-context'));
|
||||
assert.equal(init.headers['x-requester-id'], USER);
|
||||
assert.equal(init.headers['x-requester-tenant-id'], ROOT);
|
||||
if (init.method === 'GET') return json({ id: FEEDBACK, requester_id: USER, is_secret: false });
|
||||
const body = JSON.parse(init.body);
|
||||
assert.equal(body.requester_id, undefined);
|
||||
assert.equal(body.requester_affiliation, undefined);
|
||||
@@ -219,6 +220,7 @@ for (const method of ['GET', 'PATCH', 'DELETE']) {
|
||||
assert.ok(!String(url).includes('org-context'));
|
||||
assert.equal(init.headers['x-requester-id'], USER);
|
||||
assert.equal(init.headers['x-requester-tenant-id'], ROOT);
|
||||
if (method !== 'GET' && init.method === 'GET') return json({ id: FEEDBACK, requester_id: USER, is_secret: false });
|
||||
assert.equal(init.method, method);
|
||||
return json({ message: 'Forbidden resource' }, 403);
|
||||
}, async () => {
|
||||
|
||||
Reference in new issue
Block a user