๐Ÿ” TDD csrf token ๋ฌธ์ œ ํ•ด๊ฒฐ

์ด์Šˆ

์š”์ฆ˜ ํŒŒ์ด์ฌ์„ ์ด์šฉํ•œ ํด๋ฆฐ์ฝ”๋“œ๋ฅผ ์œ„ํ•œ ํ…Œ์ŠคํŠธ ์ฃผ๋„ ๊ฐœ๋ฐœ ์ฑ…์„ ๋”ฐ๋ผํ•ด๊ฐ€๋ฉฐ TDD๋กœ Django๋ฅผ ๋‹ค๋ฃจ๋Š” ๋ฒ•์„ ๋ฐฐ์›Œ๊ฐ€๊ณ  ์žˆ๋‹ค.

์–ด์ œ๋Š” ์ž…๋ ฅ ํผ์„ ๋งŒ๋“œ๋Š” ๋ถ€๋ถ„์„ ๋งŒ๋“ค๊ณ  ํ…Œ์ŠคํŠธํ•ด๋ณด์•˜๋Š”๋ฐ ์ž๊พธ ์‹คํŒจ๋ฅผ ํ–ˆ๋‹ค. ์ด์œ ๋Š” form์•ˆ์˜ csrf token ๋•Œ๋ฌธ์ด์—ˆ๋‹ค.

CSRF(Cross-Site Request Forgery)๋ž€

CSRF๋Š” ์›น์‚ฌ์ดํŠธ ์ทจ์•ฝ์  ๊ณต๊ฒฉ์˜ ํ•˜๋‚˜๋กœ, ์‚ฌ์šฉ์ž๊ฐ€ ์ž์‹ ์˜ ์˜์ง€์™€๋Š” ๋ฌด๊ด€ํ•˜๊ฒŒ ๊ณต๊ฒฉ์ž๊ฐ€ ์˜๋„ํ•œ ํ–‰์œ„(์ˆ˜์ •, ์‚ญ์ œ, ๋“ฑ๋ก ๋“ฑ)๋ฅผ ํŠน์ • ์›น์‚ฌ์ดํŠธ์— ์š”์ฒญํ•˜๊ฒŒ ํ•˜๋Š” ๊ณต๊ฒฉ์„ ๋งํ•œ๋‹ค. ์‚ฌ์ดํŠธ ๊ฐ„ ์Šคํฌ๋ฆฝํŒ…(XSS)์„ ์ด์šฉํ•œ ๊ณต๊ฒฉ์ด ์‚ฌ์šฉ์ž๊ฐ€ ํŠน์ • ์›น์‚ฌ์ดํŠธ๋ฅผ ์‹ ์šฉํ•˜๋Š” ์ ์„ ๋…ธ๋ฆฐ ๊ฒƒ์ด๋ผ๋ฉด, CSRF๋Š” ํŠน์ • ์›น์‚ฌ์ดํŠธ๊ฐ€ ์‚ฌ์šฉ์ž์˜ ์›น ๋ธŒ๋ผ์šฐ์ €๋ฅผ ์‹ ์šฉํ•˜๋Š” ์ƒํƒœ๋ฅผ ๋…ธ๋ฆฐ ๊ฒƒ์ด๋‹ค. ์ผ๋‹จ ์‚ฌ์šฉ์ž๊ฐ€ ์›น์‚ฌ์ดํŠธ์— ๋กœ๊ทธ์ธํ•œ ์ƒํƒœ์—์„œ ์‚ฌ์ดํŠธ๊ฐ„ ์š”์ฒญ ์œ„์กฐ ๊ณต๊ฒฉ ์ฝ”๋“œ๊ฐ€ ์‚ฝ์ž…๋œ ํŽ˜์ด์ง€๋ฅผ ์—ด๋ฉด, ๊ณต๊ฒฉ ๋Œ€์ƒ์ด ๋˜๋Š” ์›น์‚ฌ์ดํŠธ๋Š” ์œ„์กฐ๋œ ๊ณต๊ฒฉ ๋ช…๋ น์ด ๋ฏฟ์„ ์ˆ˜ ์žˆ๋Š” ์‚ฌ์šฉ์ž๋กœ๋ถ€ํ„ฐ ๋ฐœ์†ก๋œ ๊ฒƒ์œผ๋กœ ํŒ๋‹จํ•˜๊ฒŒ ๋˜์–ด ๊ณต๊ฒฉ์— ๋…ธ์ถœ๋œ๋‹ค.

์ด์™€ ๊ฐ™์€ CSRF ๊ณต๊ฒฉ์„ ๋ง‰๊ธฐ ์œ„ํ•œ ์ˆ˜๋‹จ์œผ๋กœ token์„ ์‚ฌ์šฉํ•˜์—ฌ ์‚ฌ์šฉ์ž๋ฅผ ์ธ์ฆ์„ ํ•˜๋Š” ๋ฐฉ์‹์ด ์žˆ๋‹ค. Django์—์„œ๋Š” form ํƒœ๊ทธ ์•ˆ์— ์ถ”๊ฐ€ํ•˜์—ฌ ๊ฐ„ํŽธํ•˜๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.

ํ•˜์ง€๋งŒ ์ด token์„ ์ถ”๊ฐ€ํ•˜๋ฉด์„œ ํ…Œ์ŠคํŠธํ•˜๊ธฐ๊ฐ€ ๋ฒˆ๊ฑฐ๋กœ์›Œ์กŒ๋‹ค. ์‘๋‹ต์„ ๋ฐ›์€ html์—๋Š” hidden type์˜ input์œผ๋กœ token์ด ์ƒ์„ฑ๋˜์–ด์žˆ์ง€๋งŒ, template html์—๋Š” ์ƒ์„ฑ๋˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค.

FAIL: test_home_page_returns_correct_html (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/yugyeong/Study/tdd-4-clean-code/superlists/lists/tests.py", line 19, in test_home_page_returns_correct_html
    self.assertEqual(response.content.decode(), expected_html)
AssertionError: '<htm[229 chars]     <input type="hidden" name="csrfmiddleware[206 chars]l>\n' != '<htm[229 chars]     \n        </form>\n        <table id="id_[85 chars]l>\n'

๊ณ ๋ฏผ

์ด ๋ฌธ์ œ๋ฅผ ๋งŒ๋‚˜๊ธฐ ์ „๊นŒ์ง€์˜ ํ…Œ์ŠคํŠธ๋Š” ๋งค์šฐ ์ˆœ์กฐ๋กœ์› ๋‹ค. ๋น„๊ตํ•˜๋Š” ๋Œ€์ƒ์˜ ๊ฐ’์„ ๋ชจ๋‘ ์•Œ๊ณ  ์žˆ์—ˆ๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค.

์–ธ์  ๊ฐ€ ๋žœ๋คํ•œ ๊ฐ’์€ ์–ด๋–ป๊ฒŒ ํ…Œ์ŠคํŠธํ•˜์ง€? ๋ผ๋Š” ์˜๋ฌธ์ด ๋“  ์ ์ด ์žˆ์—ˆ๋‹ค. ์ด ์ฝ”๋“œ๋Š” ์ข…ํ˜ธ์˜ค๋น ๊ฐ€ ์ฐธ๊ณ ํ•˜๋ผ๊ณ  ๋ณด๋‚ด์ฃผ์‹  ๋งํฌ ์† ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์–ด๋ ค์šด ์ฝ”๋“œ์— ํฌํ•จ๋œ๋‹ค๊ณ  ์ƒ๊ฐํ–ˆ๋‹ค.

๋‚ด๊ฐ€ ์ƒ๊ฐํ•œ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•์€ ์ด 3๊ฐ€์ง€์˜€๋‹ค.

  1. ํ…Œ์ŠคํŠธ์šฉ html ํŒŒ์ผ์„ ๋”ฐ๋กœ ๋งŒ๋“ค์ž.
  2. ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์‰ฌ์šด ์ฝ”๋“œ์™€ ์–ด๋ ค์šด ์ฝ”๋“œ๋ฅผ ๋ถ„๋ฆฌํ•˜์ž.
  3. ํ…Œ์ŠคํŠธ์—์„œ ์ œ์™ธ์‹œํ‚ค์ž.

1๋ฒˆ์€ ์ฐธ ๋ฌด์‹ํ•œ ๋ฐฉ๋ฒ•์ด๋‹ค. ํ…Œ์ŠคํŠธ์šฉ html ํŒŒ์ผ์„ ๋˜ ํ•˜๋‚˜ ๋งŒ๋“ค๊ฒŒ๋˜๋ฉด, ์ด๋ฒˆ์€ ์‰ฝ๊ฒŒ ๋„˜์–ด๊ฐˆ์ง€๋ผ๋„ ์•ž์œผ๋กœ ๋น„์šฉ์ด ์–ด๋งˆ์–ด๋งˆํ•˜๊ฒŒ ๋“ค ๊ฒƒ์ด๋‹ค.

2๋ฒˆ์€ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์‰ฌ์šด ์ฝ”๋“œ์™€ ์–ด๋ ค์šด ์ฝ”๋“œ๋ฅผ ๋ถ„๋ฆฌํ•˜๋Š” ๋ฐฉ๋ฒ•(์ข…ํ˜ธ์˜ค๋น  ์ถ”์ฒœ ๋ฐฉ๋ฒ•!)์ด๋‹ค. ํด๋ฆฐ ์•„ํ‚คํ…์ฒ˜์™€ ํ•จ๊ป˜ ์ƒ๊ฐํ•ด๋ณด๋ฉด csrf token์„ ์‚ฌ์šฉํ•˜๋Š” ๋ถ€๋ถ„์€ ์›์˜ ๊ฐ€์žฅ ๋ฐ”๊นฅ์ชฝ์ด ๋˜๊ณ  html์€ ์ข€ ๋” ์•ˆ์ชฝ ์›์— ์œ„์น˜ํ•œ๋‹ค. ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์–ด๋ ค์šด ์ฝ”๋“œ๋ฅผ ๋”ฐ๋กœ ๋นผ์„œ ํ…Œ์ŠคํŠธํ•˜๋Š” ๋ฐฉ๋ฒ•์ด๋‹ค. (๋ถ„๋ฆฌ๋„ ํ•˜๊ณ  ํ…Œ์ŠคํŠธ๋„ ํ•˜๋Š”) ๊ฐ€์žฅ ์ด์ƒ์ ์ธ ๋ฐฉ๋ฒ•์ด์ง€๋งŒ, ํ˜„์žฌ ํ…Œ์ŠคํŠธํ•˜๊ณ  ์žˆ๋Š” ๋ถ€๋ถ„(html)์€ ๋ถ„๋ฆฌํ•œ๋‹ค๊ณ  ํ•ด์„œ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ”๊ฟ€ ์ˆ˜ ์žˆ๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ๋Š” ํŒ๋‹จ์ด ์„ฐ๋‹ค.

3๋ฒˆ์€ ํ…Œ์ŠคํŠธ์—์„œ ์ œ์™ธ์‹œํ‚ค๋Š” ๋ฐฉ๋ฒ•์ด๋‹ค. ๊ฐ€์žฅ ์‰ฝ๊ณ  ๋น ๋ฅด๊ฒŒ ํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐฉ๋ฒ•์ด๊ธฐ๋„ ํ•˜๋‹ค.

๋‚˜๋Š” ๊ณ ๋ฏผ ๋์— 3๋ฒˆ์„ ํƒํ–ˆ๋‹ค. csrf token์€ ํ•ญ์ƒ ๋ณ€๊ฒฝ๋˜๋Š” ๊ฐ’์ด๊ธฐ ๋•Œ๋ฌธ์— ํ…Œ์ŠคํŠธ์—์„œ ์ œ์™ธํ•œ๋‹ค๊ณ  ํ•ด์„œ ์‹ค์ œ ์ฝ”๋“œ์— ์˜ํ–ฅ์ด ์žˆ๋Š” ๊ฒƒ์€ ์•„๋‹ˆ๋ฏ€๋กœ ๊ดœ์ฐฎ์„ ๊ฒƒ์ด๋ผ ํŒ๋‹จํ–ˆ๋‹ค.

๋” ๋‚˜์€ ๋ฐฉ๋ฒ•์œผ๋กœ ํ•ด๊ฒฐํ•˜์‹  ๋ถ„์ด ๊ณ„์‹œ๋‹ค๋ฉด ๋Œ“๊ธ€๋กœ ๋‚จ๊ฒจ์ฃผ์‹œ๋ฉด ๊ฐ์‚ฌํ•˜๊ฒ ์Šต๋‹ˆ๋‹น :)

ํ•ด๊ฒฐ

์Šคํƒ ์˜ค๋ฒ„ํ”Œ๋กœ์šฐ์—์„œ ๋‚˜์™€ ๊ฐ™์€ ์ด์Šˆ๋ฅผ ๊ฒช๊ณ  ์žˆ๋Š” ์งˆ๋ฌธ์„ ๋ฐœ๊ฒฌํ–ˆ๋‹ค. ๊ทธ์— ๋Œ€ํ•œ ๋‹ต ์ค‘์— ์ •๊ทœ์‹์œผ๋กœ csrf token ๋ถ€๋ถ„์„ ์ฐพ์•„๋‚ด์–ด ์‚ญ์ œํ•œ ํ›„ ๋น„๊ตํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ๋ณด์•˜๊ณ  ๋‚˜๋Š” ์ด๋ฅผ ์ ์šฉํ•˜๊ธฐ๋กœ ํ–ˆ๋‹ค.

๋‹ค์Œ์€ test.py์˜ HomePageTest ํด๋ž˜์Šค์˜ ์ „์ฒด ์ฝ”๋“œ์ด๋‹ค.

import re

class HomePageTest(TestCase):

    @staticmethod
    def remove_csrf(html_code):
        csrf_regex = r'<input[^>]+csrfmiddlewaretoken[^>]+>'
        return re.sub(csrf_regex, '', html_code)

    def assertEqualExceptCSRF(self, html_code1, html_code2):
        return self.assertEqual(
            self.remove_csrf(html_code1),
            self.remove_csrf(html_code2)
        )

    def test_root_url_resolves_to_home_page_view(self):
        found = resolve('/')
        self.assertEqual(found.func, home_page)

    def test_home_page_returns_correct_html(self):
        request = HttpRequest()
        response = home_page(request)
        self.assertEqualExceptCSRF(
            render_to_string('home.html', request=request),
            response.content.decode()
        )

    def test_home_page_can_save_a_POST_request(self):
        request = HttpRequest()
        request.method = 'POST'
        request.POST['item_text'] = '์‹ ๊ทœ ์ž‘์—… ์•„์ดํ…œ'

        response = home_page(request)

        self.assertIn('์‹ ๊ทœ ์ž‘์—… ์•„์ดํ…œ', response.content.decode())
        self.assertEqualExceptCSRF(
            render_to_string('home.html', {'new_item_text' : '์‹ ๊ทœ ์ž‘์—… ์•„์ดํ…œ'}),
            response.content.decode()
        )

์œ„ ์ฝ”๋“œ๋ฅผ ์‹คํ–‰ํ•˜๋ฉด ๋ฌธ์ œ ์—†์ด ํ…Œ์ŠคํŠธ๊ฐ€ ํ†ต๊ณผ๋  ๊ฒƒ์ด๋‹ค.

์•„์ง TDD ์ดˆ์งœ๋ผ ์ฑ…์„ ๋”ฐ๋ผ๊ฐ€๋Š” ๊ฒƒ๋„ ์กฐ๊ธˆ์€ ๋ฒ…์ฐจ๋‹ค.. ํ•œ ์žฅ ๋„˜๊ธธ๋•Œ๋งˆ๋‹ค ์‚ฝ์งˆ ์ค‘์ด๊ธด ํ•œ๋ฐ, ๊ทธ๋ž˜๋„ ์žฌ๋ฐŒ๋‹น :D

References


Written by@ugaemi
Record things I want to remember

๐Ÿฑ GitHub๐Ÿ“š Reading Space