fix: enforce private feedback access across list and detail
Deploy EG-BIM QA Gateway / deploy (push) Successful in 50s

This commit is contained in:
root committed 2026-11-13 10:28:00 +09:00
1 parent e2e70c13ac
commit a5a5f24f92
10 files changed
+379 -24

No files matched your search

+40
View File
@@ -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();}
+132
View File
@@ -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);
});
});
}
+36 -5
View File
@@ -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);
+16
View File
@@ -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 } }));
+2 -1
View File
@@ -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 = (
+2
View File
@@ -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 () => {