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

+3 -1
View File
@@ -35,7 +35,9 @@ python3 -m http.server 4173
상세 페이지에는 공개 댓글 입력 UI가 있으며, 댓글은 같은 Worker를 통해 ABC의 `feedback_comments`에 저장하고 `is_internal=false`인 공개 댓글만 조회합니다. 댓글 이미지가 있으면 `attachments` multipart 필드로 Worker가 ABC에 전달하며, 응답으로 받은 이미지 URL을 썸네일로 표시합니다. 목록·상세 진입 시에는 ABC의 `feedback_status`를 다시 조회해 관리페이지에서 변경된 상태를 반영합니다.
게시글과 댓글의 수정·삭제 버튼은 로그인 사용자의 기본 requester UUID와 작성자 ID가 일치할 때만 표시합니다. 이름·이메일이 같다는 이유로 표시하지 않으며, 작성자 ID가 없거나 로그인 확인 중·만료·조회 실패 상태이면 숨깁니다. 다른 사람의 게시글에서도 본인이 작성한 댓글의 버튼은 표시합니다. 공통 버튼 CSS는 `hidden` 속성을 덮어쓰지 않습니다.
게시글과 댓글의 수정·삭제 버튼은 로그인 사용자의 기본 requester UUID와 작성자 ID가 일치할 때만 표시합니다. 이름·이메일이 같다는 이유로 표시하지 않으며, 작성자 ID가 없거나 로그인 확인 중·만료·조회 실패 상태이면 숨깁니다. 조회 가능한 다른 사람의 공개 게시글에서도 본인이 작성한 댓글의 버튼은 표시합니다. 공통 버튼 CSS는 `hidden` 속성을 덮어쓰지 않습니다.
Q&A 화면의 비밀글은 작성자 본인만 조회합니다. Worker는 공용 ABC API Key로 받은 응답을 다시 검사하며, 다른 작성자의 비밀글은 목록에 `비밀글입니다.`와 분류·등록일·상태만 전달합니다. 제목·본문·작성자 정보·첨부 URL은 전달하지 않으며, 상세·댓글 조회와 관련 변경 요청도 차단합니다. 같은 회사·이름·이메일이나 일반 SSO 역할 문자열로 조회 권한을 부여하지 않습니다. 관리페이지의 담당자 권한은 관리페이지에서 별도로 처리합니다. 검색은 이렇게 제한된 목록에서 실행하며, 공용 API Key로 임의 필드를 검색해 비밀글 내용을 추측하는 요청은 허용하지 않습니다. 이전 브라우저 캐시는 진입 시 지우고, 조회가 거절되거나 실패해도 저장된 본문·댓글로 대체하지 않습니다. 비밀글 본문과 댓글은 새로 브라우저 저장소에 저장하지 않습니다.
목록 검색은 제목·내용·회사·부서·작성자를 대상으로 오타, 띄어쓰기·대소문자 차이, 두 글자 이상의 한글 초성을 처리합니다. 예를 들어 `로그임`, `ㄹㄱㅇ`으로 `로그인`을, `파일첨부`로 `파일 첨부`를 찾을 수 있습니다. 검색 버튼이나 Enter로 실행하며, 여러 단어를 입력하면 각 단어가 모두 일치해야 합니다. 정확한 제목 일치를 우선하고 같은 점수는 기존 목록 순서를 유지합니다. 분류와 `내가 작성한 글` 조건도 함께 적용합니다.
+45 -14
View File
@@ -129,6 +129,7 @@
createdAt: createdAt,
status: normalizeStatus(value(["feedback_status", "feedbackStatus", "status_code", "status"])),
secret: toBoolean(secret),
accessRestricted: toBoolean(item.access_restricted || item.accessRestricted),
completionRequested: toBoolean(completionRequested) || Boolean(completionRequestedAt),
completionRequestedAt: completionRequestedAt,
completionConfirmed: toBoolean(completionConfirmed) || Boolean(completionConfirmedAt),
@@ -249,12 +250,14 @@
}
function savePost(post) {
if (post.secret) return;
const posts = readJson(STORAGE_KEY, []);
posts.unshift(post);
localStorage.setItem(STORAGE_KEY, JSON.stringify(posts));
}
function updateStoredPost(post) {
if (post.secret) { removeStoredPost(post.id); return; }
const posts = readJson(STORAGE_KEY, []);
const index = posts.findIndex(function (item) { return item.id === post.id; });
if (index < 0) return;
@@ -279,6 +282,10 @@
return Boolean(post) && matchesAuthorId(post.requesterId || post.requesterUuid, user);
}
function isRestrictedPost(post, user) {
return post.accessRestricted || (post.secret && !isPostOwner(post, user));
}
function normalizeStatus(value) {
if (value && typeof value === "object") value = value.code || value.key || value.value || value.name || "";
const raw = String(value || "").trim();
@@ -496,6 +503,8 @@
}
function bindGlobal() {
// Discard legacy snapshots that could have been saved under another account.
try { localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(COMMENTS_KEY); } catch (error) {}
renderAuth(getAuthUser());
authReady = loadAuthUser().catch(function (error) {
console.error('[QA] SSO session load failed:', error);
@@ -544,7 +553,9 @@
const response = await fetch(target, Object.assign({ credentials: 'include' }, options));
if (!response.ok) {
const errorBody = await response.json().catch(function () { return {}; });
throw new Error(errorBody.message || errorBody.error || 'API 요청에 실패했습니다. (' + response.status + ')');
const error = new Error(errorBody.message || errorBody.error || 'API 요청에 실패했습니다. (' + response.status + ')');
error.status = response.status;
throw error;
}
return response.json().catch(function () { return {}; });
}
@@ -598,7 +609,8 @@
const resultCount = qs("#resultCount");
const pagination = qs("#pagination");
let page = 1;
let posts = getPosts();
// A previous account's browser cache is never an authorization source.
let posts = [];
let remoteLoaded = false;
let loadFailed = false;
let receivedRemotePage = false;
@@ -620,7 +632,11 @@
const categoryFilter = selectedCategory ? selectedCategory.value : "전체";
const onlyMine = qs("#onlyMine").checked;
const user = getAuthUser();
const filtered = searchPosts(posts.filter(function (post) {
const visiblePosts = posts.map(function (post) {
if (!isRestrictedPost(post, user)) return post;
return Object.assign({}, post, { title: '비밀글입니다.', content: '', company: '-', department: '-', author: '-', requesterEmail: '', raw: {}, attachments: [] });
});
const filtered = searchPosts(visiblePosts.filter(function (post) {
const matchesCategory = categoryFilter === "전체" || categoryFilter === post.category;
const matchesMine = !onlyMine || isPostOwner(post, user);
return matchesCategory && matchesMine;
@@ -644,13 +660,17 @@
const status = normalizeStatus(post.status);
const statusClass = status === '완료' ? 'done' : status === '신규' ? 'new' : status === '보류' ? 'hold' : status === '진행중' ? 'patch' : 'review';
const company = getPostCompany(post, user);
const restricted = isRestrictedPost(post, user);
const subject = restricted ? '<span class="subject-link" aria-disabled="true"><span class="lock" aria-label="비밀글">🔒</span> 비밀글입니다.</span>' :
'<a class="subject-link" title="' + escapeHtml(post.title) + '" href="detail.html?id=' + encodeURIComponent(post.id) + '">' + (post.secret ? '<span class="lock" aria-label="비밀글">🔒</span>' : '') + '<span>' + escapeHtml(post.title) + '</span></a>';
return "<tr class=\"" + (isNotice ? "notice-row" : "") + "\" data-id=\"" + escapeHtml(post.id) + "\">" +
"<td>" + number + "</td><td class=\"category-cell\">" + escapeHtml(post.category.replace("문의", "")) + "</td>" +
'<td title="' + escapeHtml(company) + '">' + escapeHtml(company) + '</td><td title="' + escapeHtml(post.department) + '">' + escapeHtml(post.department) + '</td><td>' + escapeHtml(post.author) + '</td>' +
'<td class="inquiry-subject"><a class="subject-link" title="' + escapeHtml(post.title) + '" href="detail.html?id=' + encodeURIComponent(post.id) + '">' + (post.secret ? '<span class="lock" aria-label="비밀글">🔒</span>' : '') + '<span>' + escapeHtml(post.title) + '</span></a></td>' +
'<td class="inquiry-subject">' + subject + '</td>' +
'<td title="' + escapeHtml(post.dateTime || post.date) + '">' + escapeHtml(post.dateTime || post.date) + '</td><td>' + (status ? '<span class="inquiry-status inquiry-status--' + statusClass + '">' + escapeHtml(status) + '</span>' : '-') + '</td></tr>';
}).join("");
qsa("#qaRows tr").forEach(function (row) { row.addEventListener("click", function (event) {
if (!row.querySelector('a')) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
if (inquiryUI) { event.preventDefault(); inquiryUI.open('detail', row.dataset.id, row.querySelector('a')); }
else window.location.href = 'detail.html?id=' + encodeURIComponent(row.dataset.id);
@@ -745,22 +765,32 @@
const bottomBackButton = find('#backButton', detail);
if (bottomBackButton && bottomBackButton.parentElement) bottomBackButton.parentElement.remove();
const id = requestedId || new URLSearchParams(window.location.search).get("id") || "EG-1048";
const storedPost = getPosts().find(function (item) { return item.id === id; });
let post = storedPost;
let post;
let loadError;
try {
const result = await requestJson("/api/feedbacks/" + encodeURIComponent(id), { method: "GET" });
const remotePost = normalizeRemotePost(result.feedback || result.data || result);
if (remotePost.id) post = Object.assign({}, storedPost || {}, remotePost);
if (remotePost.id) post = remotePost;
} catch (error) {
loadError = error;
console.warn("[QA] feedback detail load failed:", error.message);
}
const showUnavailable = function (restricted) {
find('[data-detail-title]').textContent = restricted ? '비밀글입니다.' : '문의 내용을 찾을 수 없습니다.';
find('[data-detail-content]').textContent = restricted ? '비밀글은 작성자만 조회할 수 있습니다.' : '문의 내용을 불러오지 못했습니다. 다시 시도해 주세요.';
['.comment-box', '#commentForm', '#comments', '#editButton', '#deleteButton', '#editForm', '[data-detail-attachments]'].forEach(function (selector) {
const element = find(selector);
if (element) { element.hidden = true; }
});
};
if (!post) {
find('[data-detail-title]').textContent = '문의 내용을 찾을 수 없습니다.';
find('[data-detail-content]').textContent = '목록에 등록된 문의가 없습니다.';
const commentBox = find('.comment-box');
if (commentBox) commentBox.hidden = true;
showUnavailable(loadError && loadError.status === 403);
return;
}
// Private data must wait for verified identity before touching the DOM.
if (!post.secret && !post.accessRestricted) find('[data-detail-title]').textContent = post.title;
const detailUser = await (authReady || Promise.resolve(getAuthUser()));
if (isRestrictedPost(post, detailUser)) { showUnavailable(true); return; }
find('[data-detail-category]').textContent = post.category;
find('[data-detail-title]').textContent = post.title;
const renderDetailStatus = function () {
@@ -775,7 +805,6 @@
renderDetailStatus();
find('[data-detail-date]').textContent = post.dateTime || post.date;
find('[data-detail-author]').textContent = post.author;
const detailUser = await (authReady || Promise.resolve(getAuthUser()));
find('[data-detail-company]').textContent = getPostCompanyLabel(post, detailUser);
find('[data-detail-department]').textContent = post.department;
find('[data-detail-content]').textContent = post.content;
@@ -979,11 +1008,13 @@
return '<div class="comment" data-comment-id="' + escapeHtml(comment.id) + '"><div class="comment-meta"><strong>' + escapeHtml(comment.author) + '</strong><span>' + escapeHtml(comment.date) + '</span></div>' + content + (comment.attachments.length ? '<div class="comment-attachments-list">' + renderCommentAttachments(comment.attachments) + '</div>' : '') + (editing ? '' : actions) + '</div>';
}).join('') : '<div class="comment-empty">등록된 답변이 없습니다.</div>';
const storedComments = getComments();
storedComments[post.id] = comments;
if (post.secret) delete storedComments[post.id];
else storedComments[post.id] = comments;
localStorage.setItem(COMMENTS_KEY, JSON.stringify(storedComments));
return comments;
}
let comments = renderComments(getComments()[post.id] || []);
// Refresh access on every open; cached comments may belong to another login.
let comments = renderComments([]);
const commentsPath = '/api/feedbacks/' + encodeURIComponent(post.id) + '/comments';
requestJson(commentsPath, { method: 'GET' }).then(function (result) {
comments = renderComments(result.comments || result.items || []);
+52
View File
@@ -0,0 +1,52 @@
// The integration API key identifies this application, not the person viewing a post.
// Never treat a successful upstream response as permission to read a private post.
function field(record, keys) {
const sources = [record, record?.fields, record?.metadata, record?.meta, record?.extra_fields, record?.data];
for (const source of sources) {
if (!source || typeof source !== 'object') continue;
for (const key of keys) {
const value = Array.isArray(source)
? source.find(item => item && (item.key === key || item.fieldKey === key || item.name === key))?.value
: source[key];
if (value !== undefined && value !== null && value !== '') return value;
}
}
return '';
}
export function isPrivateFeedback(record) {
// Any private flag wins over a conflicting public alias. Unknown flags fail closed.
const sources = [record, record?.fields, record?.metadata, record?.meta, record?.extra_fields, record?.data];
const keys = ['is_secret', 'isSecret', 'secret'];
return sources.some(source => {
const values = Array.isArray(source)
? source.filter(item => item && keys.includes(item.key || item.fieldKey || item.name)).map(item => item.value)
: keys.map(key => source?.[key]);
return values.some(value => value != null && !['', 'false', '0', 'no', 'n'].includes(String(value).trim().toLowerCase()));
});
}
export function canReadFeedback(record, user) {
if (!isPrivateFeedback(record)) return true;
const normalize = value => String(value || '').trim().toLowerCase();
const requester = record?.requester || record?.author || record?.user;
const owner = normalize(field(record, ['requester_id', 'requesterId', 'requester_uuid']) ||
field(requester, ['id', 'uuid', 'userId']));
const viewer = normalize(user?.requesterId || user?.ssoSubject || user?.userUuid);
// SSO role labels do not establish support/admin access to this feedback project.
return Boolean(owner && viewer && owner === viewer);
}
export function feedbackListItem(record, user) {
if (canReadFeedback(record, user)) return record;
// Build a new object: spreading the record could leak nested contents or signed URLs.
return {
id: field(record, ['id', 'feedbackId', 'feedback_id']),
is_secret: true,
access_restricted: true,
title: '비밀글입니다.',
Category: field(record, ['Category', 'category']),
created_at: field(record, ['createdAt', 'created_at', 'date']),
feedback_status: field(record, ['feedback_status', 'feedbackStatus', 'status_code', 'status'])
};
}
+51 -3
View File
@@ -1,4 +1,5 @@
import { enrichUserOrganization } from './org-context.js';
import { canReadFeedback, feedbackListItem } from './feedback-access.js';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
@@ -472,14 +473,20 @@ async function listFeedbacks(request, env, url) {
const searchText = url.searchParams.get("searchText");
const fieldKey = url.searchParams.get("fieldKey");
const issueName = url.searchParams.get("issueName");
if (searchText && fieldKey) payload.queries = [{ key: fieldKey, value: searchText, condition: "CONTAINS" }];
// The UI searches the sanitized list locally. Admin-key searches could otherwise
// reveal private words by whether a locked record matches a supplied query.
if (searchText || fieldKey) return json({ error: 'use_client_side_search' }, 400);
if (issueName) payload.queries = [{ key: "issueName", value: issueName, condition: "IS" }];
let response;
try {
response = await fetch(endpoint, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/json", "x-api-key": env.ABC_API_KEY },
headers: {
accept: "application/json", "content-type": "application/json", "x-api-key": env.ABC_API_KEY,
'x-requester-id': String(user.requesterId || user.ssoSubject || user.userUuid || ''),
'x-requester-tenant-id': String(user.requesterTenantId || user.tenantId || '')
},
body: JSON.stringify(payload)
});
} catch (error) {
@@ -496,7 +503,33 @@ async function listFeedbacks(request, env, url) {
const items = Array.isArray(result) ? result : (result.items || (Array.isArray(result.data) ? result.data : result.data && result.data.items) || []);
const meta = result.meta || result.data && result.data.meta || {};
return json({ items: Array.isArray(items) ? items : [], meta });
return json({ items: Array.isArray(items) ? items.map(item => feedbackListItem(item, user)) : [], meta });
}
function privateFeedbackDenied() {
return json({ error: 'private_feedback_forbidden', message: '비밀글은 작성자만 조회할 수 있습니다.' }, 403);
}
async function checkFeedbackAccess(env, feedbackId, user) {
const base = String(env.ABC_API_BASE_URL || 'https://feedback.hmac.kr').replace(/\/$/, '');
const endpoint = `${base}/api/projects/${encodeURIComponent(env.ABC_PROJECT_ID)}/channels/${encodeURIComponent(env.ABC_CHANNEL_ID)}/feedbacks/${encodeURIComponent(feedbackId)}`;
let response;
try {
response = await fetch(endpoint, { method: 'GET', redirect: 'manual', headers: {
accept: 'application/json', 'x-api-key': env.ABC_API_KEY,
'x-requester-id': String(user.requesterId || user.ssoSubject || user.userUuid || ''),
'x-requester-tenant-id': String(user.requesterTenantId || user.tenantId || '')
} });
} catch {
return json({ error: 'feedback_api_unreachable' }, 502);
}
if (!response.ok) return json({ error: 'feedback_access_check_failed' }, response.status >= 500 ? 502 : response.status);
const result = await response.json().catch(() => null);
const feedback = result?.feedback || result?.data || result;
if (!feedback || typeof feedback !== 'object' || Array.isArray(feedback) || !(feedback.id || feedback.feedbackId || feedback.feedback_id)) {
return json({ error: 'invalid_feedback_response' }, 502);
}
return canReadFeedback(feedback, user) ? null : privateFeedbackDenied();
}
async function completionConfirmation(request, env, url) {
@@ -509,6 +542,9 @@ async function completionConfirmation(request, env, url) {
const feedbackId = decodeURIComponent(url.pathname.split("/")[3] || "");
if (!isUuidv7(feedbackId)) return json({ error: "invalid_feedback_id" }, 400);
const accessError = await checkFeedbackAccess(env, feedbackId, user);
if (accessError) return accessError;
const requesterId = String(user.requesterId || user.ssoSubject || user.userUuid || "");
const requesterTenantId = String(user.requesterTenantId || user.tenantId || "");
if (!requesterId || !requesterTenantId) return json({ error: "requester_identity_missing" }, 422);
@@ -556,6 +592,9 @@ async function feedbackComments(request, env, url) {
const feedbackId = decodeURIComponent(url.pathname.split('/')[3] || '');
if (!isUuidv7(feedbackId)) return json({ error: 'invalid_feedback_id' }, 400);
const accessError = await checkFeedbackAccess(env, feedbackId, user);
if (accessError) return accessError;
const apiBaseUrl = String(env.ABC_API_BASE_URL || 'https://feedback.hmac.kr').replace(/\/$/, '');
let endpoint = `${apiBaseUrl}/api/projects/${encodeURIComponent(env.ABC_PROJECT_ID)}/channels/${encodeURIComponent(env.ABC_CHANNEL_ID)}/feedbacks/${encodeURIComponent(feedbackId)}/comments`;
const requesterId = String(user.requesterId || user.ssoSubject || user.userUuid || '');
@@ -656,6 +695,9 @@ async function feedbackCommentItem(request, env, url) {
if (!isUuidv7(feedbackId)) return json({ error: 'invalid_feedback_id' }, 400);
if (!isUuidv7(commentId)) return json({ error: 'invalid_comment_id' }, 400);
const accessError = await checkFeedbackAccess(env, feedbackId, user);
if (accessError) return accessError;
const requesterId = String(user.requesterId || user.ssoSubject || user.userUuid || '');
const requesterTenantId = String(user.requesterTenantId || user.tenantId || '');
if (!requesterId || !requesterTenantId) return json({ error: 'requester_identity_missing' }, 422);
@@ -709,6 +751,11 @@ async function feedbackDetail(request, env, url) {
const feedbackId = decodeURIComponent(url.pathname.split('/')[3] || '');
if (!isUuidv7(feedbackId)) return json({ error: 'invalid_feedback_id' }, 400);
if (request.method !== 'GET') {
const accessError = await checkFeedbackAccess(env, feedbackId, user);
if (accessError) return accessError;
}
const requesterId = String(user.requesterId || user.ssoSubject || user.userUuid || '');
const requesterTenantId = String(user.requesterTenantId || user.tenantId || '');
if (!requesterId || !requesterTenantId) return json({ error: 'requester_identity_missing' }, 422);
@@ -755,6 +802,7 @@ async function feedbackDetail(request, env, url) {
const upstreamMessage = String(result.message || result.error || result.code || '').slice(0, 240);
return json({ error: 'feedback_api_rejected', message: upstreamMessage || '문의 상세 조회에 실패했습니다.', status: response.status }, response.status >= 500 ? 502 : response.status);
}
if (request.method === 'GET' && !canReadFeedback(result.feedback || result.data || result, user)) return privateFeedbackDenied();
if (request.method === 'DELETE') return json({ ok: true, id: feedbackId });
if (request.method === 'PATCH') return json({ feedback: result.feedback || result.data || result });
return json({ feedback: result.feedback || result.data || result });
+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 () => {