← 제작기

화면은 권한이 없다

이 사이트에 게시판을 붙였습니다. 누구나 글을 쓰고 답글을 달 수 있고, 비밀번호를 걸면 글쓴이와 저만 볼 수 있는 비밀글이 됩니다. 여기까지는 흔한 게시판입니다.

이 사이트는 서버 없이 HTML 파일을 그대로 올려 두는 정적 사이트입니다. 게시판만은 어쩔 수 없이 서버가 필요했는데, 서버를 붙인 순간부터 내내 붙잡고 있던 질문은 기능에 관한 것이 아니었습니다. 브라우저에 무엇을 맡겨도 되는가.

답은 처음부터 하나였고, 기능을 더할 때마다 같은 답으로 돌아왔습니다.

브라우저로 내려간 것은 누구나 전부 열어 볼 수 있습니다. 그러니 화면은 보여 주기만 하고, 무엇을 숨기고 무엇을 믿을지는 서버가 정합니다.

비밀글은 가리지 않고 보내지 않는다

비밀글을 가장 쉽게 만드는 방법은 본문을 다 내려보낸 뒤 화면에서 가리는 것입니다. 자물쇠 아이콘을 얹고, 비밀번호가 맞을 때만 CSS 로 펼치면 됩니다. 만들기 쉽고 겉보기에는 잘 동작합니다.

실제로는 아무것도 막지 못합니다. 브라우저로 내려간 본문은 개발자 도구를 한 번 열면 그대로 읽힙니다. 잠긴 것처럼 보여도 실제로는 잠겨 있지 않고, 사용자는 그 사실을 모른 채 비밀을 적게 되니 자물쇠를 달지 않는 것보다 더 나쁩니다.

이 게시판의 목록 응답에는 비밀글 본문을 아예 담지 않습니다. 제목과 작성자와 날짜만 보내고, 본문 자리는 null 입니다.

body: root.secret && !admin ? null : root.body,

비밀번호가 맞거나 관리자로 로그인했을 때만 서버가 본문을 따로 보냅니다. 화면이 본문을 감추는 것도 아니고, 본문이 처음부터 화면에 도착하지 않습니다. 가릴 것을 손에 쥐고 있지 않으면 실수로 흘릴 일도 없습니다.

글쓴이 표시는 비밀번호로 정한다

원글을 쓴 사람이 답글을 달면 "글쓴이" 배지가 붙게 하고 싶었습니다. 가장 단순한 방법은 원글의 이름과 답글의 이름이 같으면 글쓴이로 보는 것입니다.

이름 칸은 누구나 아무 이름이나 적을 수 있는 칸이라, 원글쓴이 이름을 그대로 따라 적으면 아무나 글쓴이가 됩니다. 이름은 신원을 보증하지 못하고 표시에 그칩니다.

배지는 원글의 비밀번호를 아는지로 정합니다. 원글과 같은 비밀번호로 답글을 쓴 사람에게만 서버가 배지를 확정해 붙입니다.

/* "글쓴이" 표시는 이름이 아니라 원글의 비밀번호를 아는지로 정합니다. */
isAuthor = await verifyPassword(password, root.pw_hash);

관리자 배지도 마찬가지로 관리자 세션이 있을 때 쓴 글에만 붙습니다. 화면은 서버가 확인해서 보내 준 값을 그리기만 합니다. 화면에서 이름을 비교해 배지를 붙인다면 그 배지는 아무 의미가 없습니다.

공지: 버튼이 보인다고 권한이 있는 것은 아니다

관리자가 글을 게시판 맨 위에 고정하는 공지 기능을 넣었습니다. "공지로 올리기" 버튼은 관리자로 로그인했을 때만 화면에 나타납니다.

버튼을 숨기는 데서 끝냈다면 구멍이 생겼을 것입니다. 버튼이 없어도 그 버튼이 부르는 주소는 누구나 직접 부를 수 있습니다. 손님이 /notice 요청을 직접 보내면 화면에 버튼이 있든 없든 상관이 없습니다.

서버는 그 요청이 올 때마다 관리자 세션을 다시 확인합니다. 버튼을 숨긴 것은 편의일 뿐이고, 막는 것은 서버입니다. 코드에도 이렇게 적어 두었습니다.

이 버튼이 보이는 것 자체가 권한은 아닙니다.

손님이 요청을 직접 보내면 조용히 무시하지 않고 403 으로 답합니다. 무시하면 요청한 사람은 공지가 올라간 줄 알게 됩니다. 빈 결과는 비었다고 말해야 한다는 것과 같은 이유입니다.

링크는 그릴 때 만든다

본문에 적은 주소를 누를 수 있게 해 달라는 요청이 있었습니다. 가장 손쉬운 방법은 저장할 때 주소를 <a> 태그로 바꿔 넣는 것입니다. 그러면 사용자가 쓴 HTML 을 저장하게 되고, 그 HTML 은 글이 남아 있는 동안 계속 함께 남습니다. 남이 쓴 <script> 한 줄이 이 페이지의 권한으로 실행될 수 있는 길을 열어 두는 셈입니다.

본문은 쓴 글자 그대로 저장하고, 링크는 그릴 때만 만듭니다. 저장된 것은 언제나 글자이고, 주소로 보이는 부분만 화면에서 링크로 바뀝니다. 나중에 규칙을 바꿔도 이미 저장된 글이 위험을 품고 있지 않습니다.

그릴 때도 글자는 텍스트 노드로 넣고, 링크만 createElement 로 만들어 붙입니다. innerHTML 은 쓰지 않습니다. 남이 쓴 <script> 는 실행되지 않고 글자로만 남습니다. 알아보는 주소는 http(s) 뿐이어서, javascript: 는 걸러 내기 전에 처음부터 후보에 들지 못합니다.

같은 원칙에 내가 걸렸다

여기까지가 제품 이야기입니다. 이 원칙에는 뒷면이 있었고, 그 뒷면에 제가 걸렸습니다.

화면을 믿지 않는다고 해 놓고, 저는 제 검증 화면을 믿고 있었습니다. 게시판을 실행해 보는 로컬 시연 페이지가 자바스크립트 파일을 캐시하는 바람에, 옛 파일을 보면서 새 코드를 확인했다고 믿었습니다. 화면이 통째로 하얗게 뜨는 문제를 한참 동안 코드에서 찾았는데, 사실은 재는 자가 옛것을 재고 있었습니다.

같은 실수를 다른 모양으로도 했습니다. 진짜 데이터베이스 대신 브라우저 안의 대역으로만 확인하다가, 실제 데이터베이스를 연결한 뒤에야 비밀글이 정말 새지 않는지 확인할 수 있었습니다. 검증하는 도중에 브라우저 탭이 실서버를 가리키고 있는 것을 보지 못하고 시험용 글을 실서버에 올린 적도 있습니다.

제품에서 지킨 문장이 그대로 제게 돌아왔습니다. 화면은 권한이 없습니다. 사용자의 브라우저든 제 검증 도구든, 화면에 보이는 것과 사실은 다를 수 있습니다. 재는 자가 흔들리면 그건 아직 검증이 아니라는 이야기를 이번에는 게시판 앞에서 다시 배웠습니다.


정리하면

  • 브라우저로 내려간 것은 누구나 전부 열어 볼 수 있습니다. 감춰야 할 것은 화면에서 가리지 말고 처음부터 보내지 않습니다.
  • 신원은 자유롭게 적는 칸으로 정하지 않습니다. 글쓴이는 비밀번호를 아는지로, 관리자는 세션으로 정하고, 화면은 서버가 확인한 것만 그립니다.
  • 버튼을 숨기는 것은 편의이지 권한이 아닙니다. 그 버튼이 부르는 주소를 서버가 매번 다시 확인해야 합니다.
  • 사용자가 쓴 것을 마크업으로 저장하지 않습니다. 저장은 글자로 하고 링크는 그릴 때 만들어서, 위험을 글 안에 담아 두지 않습니다.
  • 같은 기준을 검증 도구에도 적용합니다. 내 화면이 보여 주는 것도 사실이라는 보장은 없습니다.

이 게시판에 파일 첨부를 붙였다가 하루 만에 되돌린 일은 만들 수 있는 것과 만들 가치가 있는 것에 적었습니다.

I added a board to this site. Anyone can post and reply, and setting a password makes a post secret — readable only by its author and me. So far, an ordinary board.

But this is a static site with no server. It's just HTML files served as-is, so the board alone needed a server. And the moment a server entered, the question I kept holding was not about features. It was this — what can I trust the browser with?

The answer was the same from the start, and every feature returned to it.

Anything sent to the browser can be read. So the screen only displays; the server decides what to hide and what to trust.

Secret posts — not hidden, just not sent

The easiest way to build a secret post is to send the whole body down and hide it on screen — a lock icon, and CSS that reveals it once the password matches. Easy to build, and it looks like it works.

And it's completely hollow. A body sent to the browser is one dev-tools click away. Looks locked, isn't — that's worse than no lock at all, because you write secrets into it not knowing it's open.

So the list response never carries a secret body. Title, author, date go down; the body slot is null.

body: root.secret && !admin ? null : root.body,

Only a correct password or an admin session gets the server to send the body separately. The screen doesn't hide the body — it never arrives in the first place. You can't leak what you're not holding.

The author badge — by password, not by name

I wanted an "Author" badge on replies, shown when the original poster replies. The simplest way is to compare names — if the reply's name matches the post's, it's the author.

But the name field is free text anyone can fill. Copy the original author's name and anyone becomes "Author." A name is a label, not an identity.

So the badge is decided by knowing the original post's password, not by the name. Only a reply written with the same password gets the badge, stamped by the server.

/* The "author" mark is decided by knowing the post's password, not the name. */
isAuthor = await verifyPassword(password, root.pw_hash);

The admin badge is the same — only on posts written with an admin session. The screen merely draws what the server verified and sent. A badge decided by comparing names on screen means nothing.

Notices — a visible button is not permission

I added notices, letting an admin pin a post to the top. The "Pin as notice" button appears only when logged in as admin.

Stopping at hiding the button would be a hole. The address that button calls can be called directly by anyone, button or no button. A guest just throwing a /notice request doesn't care whether a button is on their screen.

So the server re-checks the admin session on every such request. Hiding the button is only convenience; the server does the stopping. The code says as much:

The button being visible is not, in itself, permission.

When a guest throws the request directly, it isn't silently ignored — it answers 403. Ignore it and they'd think it worked, for the same reason as saying empty-handed out loud.

Links — not stored, made at draw time

Someone asked to make the URLs in a post clickable. The easiest path is to turn a URL into an <a> tag when saving. But then you're storing user-written HTML, and that HTML stays for as long as the post does. One <script> someone else wrote gets a door to run with this page's privileges.

So the body is stored as plain text, and links are made only at draw time. What's stored is always text; only the parts that look like a URL become links on screen. Change the rule later and no already-saved post is carrying the danger.

Even the drawing puts text in text nodes and builds only the link with createElement. No innerHTML. Someone else's <script> doesn't run — it stays as characters. And only http(s) is recognized — javascript: isn't filtered out, it's simply never a candidate.

The same principle bit me

That's the product side. But the principle has a back, and I got caught on it.

Having declared the screen untrustworthy, I was trusting my own verification screen. The local demo page I run the board on cached its JavaScript, so I was confirming new code while looking at old files. I chased a fully blank screen as a code bug for a while, when really the instrument was measuring the old thing.

I made the same mistake in another shape. I verified against an in-browser stand-in, not the real database, and only after connecting it could I confirm the secret body truly doesn't leak. At one point I posted a test entry to the live server, not seeing my browser tab was pointed there.

The sentence I held in the product came straight back at me. The screen has no authority — whether it's the user's browser or my own verification tool, what's shown and what's true are different things. If the instrument shakes, it isn't verification yet — I relearned it, this time in front of a board.


In short

  • Anything sent to the browser can be read. Don't hide what must stay secret — don't send it.
  • Identity isn't set by free text. "Author" is by knowing the password, "Admin" by session — the screen draws only what the server verified.
  • Hiding a button is convenience, not permission. The server must re-check the address that button calls, every time.
  • Don't store what users wrote as markup. Store text, make links at draw time. Don't press the danger into the post.
  • Hold the verification tools to the same standard. There's no guarantee your own screen shows the truth either.

Adding file attachments to this board and reverting them within a day is in Buildable Is Not the Same as Worth Building.