diff --git a/.env.example b/.env.example index 95d36fa..98e3f62 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,9 @@ CLOUDFLARE_TUNNEL_TOKEN= # 최초 배포 시에만 update로 설정해 테이블 생성 후 validate로 되돌릴 것 DDL_AUTO=validate + +# Backend only. Never put these in a VITE_* variable or a frontend build argument. +# Enable after validating the server deployment and replacing/revoking exposed keys. +GEMINI_API_KEY= +GEMINI_ENABLED=false +GEMINI_MODEL=gemini-3.6-flash diff --git a/docker-compose.pi.yml b/docker-compose.pi.yml index 4b546e5..d955561 100644 --- a/docker-compose.pi.yml +++ b/docker-compose.pi.yml @@ -72,6 +72,10 @@ services: JWT_SECRET: ${JWT_SECRET} ANALYSIS_CALLBACK_SECRET: ${ANALYSIS_CALLBACK_SECRET} SLACK_BACK_WEBHOOK: ${SLACK_BACK_WEBHOOK:-} + GEMINI_API_KEY: ${GEMINI_API_KEY:-} + GEMINI_ENABLED: ${GEMINI_ENABLED:-false} + GEMINI_MODEL: ${GEMINI_MODEL:-gemini-3.6-flash} + MEDIA_FFPROBE: ffprobe JAVA_TOOL_OPTIONS: -Xms256m -Xmx768m ports: - "8080:8080" # LAN 노출: ml-server의 콜백 수신용 (라우터는 미포워딩) diff --git a/docs/SECURITY-F01-F02-F03-F05.ko.md b/docs/SECURITY-F01-F02-F03-F05.ko.md new file mode 100644 index 0000000..ef8adf3 --- /dev/null +++ b/docs/SECURITY-F01-F02-F03-F05.ko.md @@ -0,0 +1,66 @@ +# F01·F02·F03·F05 보안 변경 운영 안내 + +작성: 2026-09-16. 코드·로컬 테스트용 브랜치: `fix/security-f01-f02-f03-f05`. +Backend, Frontend, DevOps에 같은 이름의 브랜치를 사용한다. 운영 배포와 기존 키 폐기는 별도 적용 단계다. + +## 변경과 계약 + +- F01: 브라우저의 Gemini SDK·키·직접 호출을 제거했다. 브라우저는 access 토큰으로 `POST /api/v1/videos/{videoId}/briefing`에 `{"player":"top"}` 또는 `bottom`만 전달한다. 서버가 소유권·분석 상태를 확인하고 DB 리포트로 프롬프트를 만든다. 제공자 오류 원문·헤더는 응답하지 않는다. +- F02: 영상 상세·리포트·브리핑은 `videoId + userId + deletedAt IS NULL + status != DELETED`를 만족해야 조회한다. 권한 확인 전에 S3 URL을 서명하거나 제공자를 호출하지 않는다. +- F03: 일반 API는 만료되지 않은 `token_type=access`만, 갱신은 DB 검증과 `token_type=refresh`를 함께 요구한다. 타입·만료·사용자 ID가 없는 토큰도 거부한다. 같은 초의 정상 갱신에서 발생한 unique 충돌은 삭제 flush 순서로 수정했다. +- F05: 두 파일과 메타데이터를 모두 검사한 후 저장한다. 실제 영상 컨테이너·스트림 검사, 썸네일 JPEG 재인코딩, UUID 객체 키, 서버가 정한 MIME, 파일 스트리밍 업로드를 사용한다. 기존 미디어에도 강제 CSP sandbox·nosniff·위험 형식 attachment·no-store를 적용한다. + +성공 응답의 기존 필드와 AI 콜백 계약, DB 스키마는 유지한다. 오류에는 선택적 `errorCode`가 추가된다. + +| 상태 | HTTP | errorCode | +|---|---:|---| +| 타인·삭제·없는 영상·소유자 없음 | 404 | RESOURCE_NOT_FOUND | +| 본인 영상 분석 중 | 404 | ANALYSIS_NOT_READY | +| 분석 실패 | 409 | ANALYSIS_FAILED | +| 완료지만 결과 없음 | 409 | ANALYSIS_RESULT_UNAVAILABLE | +| 브리핑 비활성·키 없음 | 503 | BRIEFING_UNAVAILABLE | +| 브리핑 한도 초과 | 429 | BRIEFING_RATE_LIMIT | +| 제공자 실패·잘못된 응답 | 502 | BRIEFING_PROVIDER_ERROR | +| 제공자 시간 초과 | 504 | BRIEFING_TIMEOUT | + +## 데이터 흐름과 저장 + +원본과 썸네일의 DB `s3Url`, `thumbnailUrl`은 객체 키다. Backend가 검증 파일을 저장하고 키를 DB에 넣는다. 브라우저에는 소유권 확인 후 1시간 presigned GET을 발급한다. AI 서버에는 별도 LAN presigned GET/PUT을 전달하고, AI 콜백은 스켈레톤·미니맵 **객체 키**를 저장한다. 일반 URL과 객체 키를 서로 대체하지 않는다. + +검증 실패는 DB/S3/AI 쓰기 전에 종료한다. 저장 도중 실패하면 이미 쓴 객체 삭제를 시도한다. 기존 AI 요청 실패의 PROCESSING 잔류와 스토리지/DB 전체 분산 트랜잭션 문제는 이번 수정에서 해결한 것으로 간주하지 않는다. + +브리핑 캐시는 소유권 확인 뒤에만 읽는다. 사용자·영상·선수·모델·프롬프트 버전·실제 프롬프트 digest를 키로 사용하므로 점수·분석값 변경 시 이전 결과를 재사용하지 않는다. 프론트의 기존 localStorage 브리핑 캐시는 더 이상 읽지 않는다. 최신 UI의 5개 H2 섹션과 선수별 타격 분류를 서버에 옮겼다. 모델 선택도 최신 develop의 `gemini-3.6-flash`를 유지한다. + +## 고정된 자원 제한 + +- 브리핑: 생성 요청 사용자별 분당 6회, 프로세스 전체 분당 30회, 동시에 2개. 동일 요청은 합친다. 캐시 최대 256개·30분. 입력 12,000자, 제공자 응답 64KiB, 결과 8,000자, 생성 1,500 토큰, 전체 요청 30초 제한. +- 제한·캐시는 단일 프로세스 메모리 기준이며 재시작 시 초기화된다. 다중 인스턴스 전환 전 공유 제한기를 도입해야 한다. 지속적인 일일 비용 상한이 아니므로 Google 프로젝트 쿼터·알림은 별도 설정한다. +- 영상: 최대 500MiB, MP4/MOV/WebM, 최대 7680×4320. 실제 ftyp/EBML 헤더와 제한된 ffprobe 결과가 모두 필요하다. 오래된 ftyp 없는 MOV 등은 거부될 수 있다. +- 썸네일: JPEG/PNG만, 최대 5MiB·가로세로 4096·8,388,608픽셀, 디코딩 전 크기 검사, JPEG 재인코딩. +- 검사 동시 실행 2개, ffprobe 최대 15초·출력 64KiB·단일 할당 32MiB·스트림 32개. 서버 임시 파일만 허용하고 외부 참조를 비활성화한다. 검사기 부재나 실패 시 업로드를 허용하는 대체 경로가 없다. +- 전체 multipart 요청 상한 510MiB. 실제 길이와 좌표에 대한 기존 계약은 유지하고 기본 범위만 먼저 검증한다. + +## 키와 호환 배포 순서 + +`GEMINI_API_KEY`, `GEMINI_ENABLED`, `GEMINI_MODEL`은 Backend 실행 환경 전용이다. Compose 예시는 기본 비활성이다. 실제 키는 `.env`에만 저장하며 파일 권한 600·Git 제외 상태를 유지한다. Frontend 빌드 인수·VITE 변수·이미지 레이어·커밋에 넣지 않는다. 키 없이도 서버는 기동되고 브리핑만 503을 반환한다. + +1. Backend 이미지에 ffprobe가 있고 새 API·기존 access 인증·업로드 검사가 동작하는지 스테이징에서 확인한다. +2. Backend와 새 Frontend를 같은 점검 시간에 적용한다. 새 Frontend만 먼저 적용하면 브리핑 API가 없으므로 실패한다. 기존 프론트의 직접 호출을 장기간 유지하지 않는다. +3. Frontend의 미디어 정책을 활성화한다. 별도 호스트 Nginx 템플릿의 미디어 location은 MinIO 직결 대신 Frontend로 전달해 동일한 정책을 거치도록 수정했다. Host(포트 포함)와 서명 query를 보존한다. +4. 새 서버 키·기능을 활성화하고 정상 브리핑을 확인한다. 노출된 현재·과거 키는 Google 관리자에서 폐기한다. 과거 번들의 사본은 키 폐기로 무력화한다. +5. CDN의 기존 미디어 응답 캐시를 갱신하고 운영 응답의 CSP, 206, 오류, 직접 링크, 영상·썸네일을 재확인한다. 서로 다른 두 테스트 계정으로 조회 차단도 확인한다. + +장애 시 브리핑만 `GEMINI_ENABLED=false`로 중단한다. 업로드 검사나 미디어 sandbox를 제거해 우회하지 않는다. DB migration은 없다. 권한 검증 없는 조회, refresh의 일반 인증, 브라우저 키 호출로 되돌리는 롤백은 사용하지 않는다. + +## 검증 구분과 잔여 항목 + +로컬 자동 테스트는 실제 H2·JWT 필터를 사용한다. S3/AI/Gemini는 테스트 종류에 따라 명시적으로 mock으로 분리하며, 별도 통합 검사는 실제 Nginx·MinIO와 합성 객체로 수행한다. 상세 실행 결과와 CI 링크는 별도 결과 보고서에 기록한다. + +- 운영 서버 배포·기존 키 폐기·CDN purge·운영의 두 계정 검증은 이 커밋에 포함되지 않는다. +- 이미 발급된 presigned URL은 소유권 수정만으로 즉시 폐기되지 않는다. 기존 유효기간과 캐시를 고려해야 한다. +- 정상 access 토큰의 로그아웃 직후 즉시 폐기나 refresh 회전 체계 전면 개선은 F15 범위로 남는다. +- 실제 AI 모델 추론, 운영 Pi 성능/디스크 여유, 실제 Safari/iOS 기기 검증은 로컬 합성 시험과 별개다. +- 기존 npm 감사 결과 13건(critical 1, high 9, moderate 2, low 1)은 이번 네 항목의 수정 범위 밖이다. 의존성 일괄 업데이트를 섞지 않았다. +- 원본 작업 폴더의 미완성 hit 로깅/AI 변경은 커밋하지 않았다. 로깅 호환 테스트는 임시 사본에서 소유자 fixture와 새 조회 인자를 반영해 별도로 실행한다. + +참고: [Gemini 모델](https://ai.google.dev/gemini-api/docs/models), [ffprobe](https://ffmpeg.org/ffprobe.html). diff --git a/pi/nginx-rallytrack.conf b/pi/nginx-rallytrack.conf index 80d11b8..62d7240 100644 --- a/pi/nginx-rallytrack.conf +++ b/pi/nginx-rallytrack.conf @@ -21,7 +21,7 @@ server { location / { proxy_pass http://127.0.0.1:8082; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; @@ -34,9 +34,10 @@ server { # MinIO presigned URL (path-style: //?X-Amz-...) # Host 헤더를 보존해야 presigned 서명이 일치한다. location /rallytrack-videos/ { - proxy_pass http://127.0.0.1:9000; + # Route through the frontend media policy; direct MinIO proxying bypasses CSP sandbox. + proxy_pass http://127.0.0.1:8082; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_buffering off; # 영상 스트리밍/Range 요청 diff --git a/tests/security/README.md b/tests/security/README.md new file mode 100644 index 0000000..b122972 --- /dev/null +++ b/tests/security/README.md @@ -0,0 +1,31 @@ +# Synthetic media security integration checks + +Use sibling `backend`, `frontend`, and `devops` checkouts on their security branches. +The stack uses dedicated synthetic MinIO credentials and loopback ports 18882/19090. +It never connects to the production database, provider or buckets. + +```sh +docker compose -p rally-security-test -f tests/security/docker-compose.yml up -d --build +python3 tests/security/verify_media_proxy.py +``` + +The Python check verifies real SigV4 PUT/GET, Range 206, legacy HTML/SVG disposition, +CSP sandbox/nosniff/no-store on 200/206/404/PUT, and the absence of media CSP on the SPA. +It saves synthetic signed URLs to `/tmp/rallytrack-media-urls.json` (one-hour expiry). + +For a browser check, replace `__MEDIA_URLS__` in `browser_media_check.js.template` +with that JSON and pass the resulting function to Playwright CLI `run-code`. +Run against Chromium and WebKit. Read `window.securityMediaResult` afterward. +It checks downloads, three simultaneous synthetic MP4 views, seeking, direct-link +playback, and that sandboxed media cannot read app localStorage. +`browser_ui_check.js` checks only the frontend using explicitly mocked API responses; +it validates player-switch cancellation, error-state distinction, and zero direct +Gemini calls. Read `window.securityUiResult` afterward. + +These browser mocks are separate from the real backend HTTP/H2/S3-mock contract tests +and the real MinIO proxy test; none proves an actual ML model inference run. +The generated clip is synthetic blue 160×120, one second, ten frames. + +```sh +docker compose -p rally-security-test -f tests/security/docker-compose.yml down -v +``` diff --git a/tests/security/browser_media_check.js.template b/tests/security/browser_media_check.js.template new file mode 100644 index 0000000..e8c2025 --- /dev/null +++ b/tests/security/browser_media_check.js.template @@ -0,0 +1,53 @@ +async (page) => { + const urls = __MEDIA_URLS__; + await page.goto('http://127.0.0.1:18882'); + await page.evaluate(() => { localStorage.setItem('safe-origin-marker','present'); localStorage.removeItem('media-executed'); }); + for (const name of ['legacy.html','legacy.svg']) { + const popup = await page.context().newPage(); + const download = popup.waitForEvent('download', {timeout:10000}); + await popup.goto(urls[name]).catch(() => {}); + await download; + await popup.close(); + } + await page.setContent(''); + await page.locator('#thumbnail').evaluate((element,url)=>{element.src=url;}, urls['thumbnail.png']); + await page.waitForFunction(()=>document.querySelector('#thumbnail').naturalWidth === 1); + for (const id of ['original','skeleton','minimap']) { + await page.locator('#'+id).evaluate((element,url) => { element.src=url; element.load(); },urls['sample.mp4']); + } + await page.waitForFunction(() => [...document.querySelectorAll('video')].every(v=>v.readyState>=2),{},{timeout:15000}); + const playback = await page.evaluate(async () => { + const videos=[...document.querySelectorAll('video')]; + for (const v of videos) { + await v.play(); + v.pause(); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Video seek did not finish')), 10000); + v.addEventListener('seeked', () => {clearTimeout(timeout);resolve();}, {once:true}); + v.currentTime = 0.5; + }); + } + return videos.map(v=>({width:v.videoWidth,height:v.videoHeight,duration:v.duration})); + }); + await page.waitForFunction(() => [...document.querySelectorAll('video')].every(v=>!v.seeking && Math.abs(v.currentTime-0.5)<0.05)); + await page.locator('#isolated').evaluate((el,url)=>{el.src=url;},urls['sample.mp4']); + await page.waitForFunction(() => document.querySelector('#isolated').contentWindow !== null); + const frame = await page.waitForEvent('framenavigated',{predicate:f=>f.url().includes('/rallytrack-videos/'),timeout:10000}).catch(()=>page.frames().find(f=>f.url().includes('/rallytrack-videos/'))); + if(!frame) throw new Error('Media frame not loaded'); + const isolated = await frame.evaluate(() => {try{localStorage.getItem('safe-origin-marker'); return false;}catch(e){return e.name==='SecurityError';}}); + if(!isolated) throw new Error('Media retained app origin storage access'); + // WebKit blocks waitForFunction's page-world polling script under CSP sandbox. + // Read through the automation world without relaxing the site's policy. + let directReady = false; + for (let attempt = 0; attempt < 50; attempt++) { + directReady = await frame.evaluate(() => document.querySelector('video')?.readyState >= 2); + if (directReady) break; + await page.waitForTimeout(100); + } + if (!directReady) throw new Error('Direct media playback failed'); + await page.locator('#isolated').evaluate((el,url)=>{el.src=url;},urls['disguised.mp4']); + await frame.waitForURL(url => url.pathname.endsWith('/disguised.mp4'), {timeout:10000}); + const marker=await page.evaluate(()=>localStorage.getItem('media-executed')); + if(marker!==null) throw new Error('Uploaded marker script ran'); + await page.evaluate(result => {window.securityMediaResult=result;}, {downloadsBlockedRendering:2,thumbnailLoaded:true,playback,mediaOriginIsolated:isolated,uploadedScriptExecuted:false}); +} \ No newline at end of file diff --git a/tests/security/browser_ui_check.js b/tests/security/browser_ui_check.js new file mode 100644 index 0000000..77ad5cc --- /dev/null +++ b/tests/security/browser_ui_check.js @@ -0,0 +1,36 @@ +async (page) => { + await page.context().clearCookies(); + await page.goto('http://127.0.0.1:18882'); + await page.evaluate(() => {localStorage.clear();localStorage.setItem('accessToken','synthetic-access');localStorage.setItem('user',JSON.stringify({id:1,nickname:'Security Test',email:'test@example.invalid'}));}); + const googleCalls=[]; const briefingCalls=[]; + page.on('request',r=>{if(r.url().includes('generativelanguage.googleapis.com'))googleCalls.push(r.url());}); + const player={positionAnalysis:{heatmapData:[]},strokeTypes:{smash:1,clear:2,drop:0,drive:0,serve:0,net:0,others:0},abilityMetrics:{aggression:85,rally:70,defense:50,mobility:30,consistency:0},aiCoaching:{feedbackText:''}}; + const report={code:200,message:'test',data:{videoId:77,summary:{myScore:3,opponentScore:2,matchOutcome:'WIN',matchTime:'1:00',totalStrokeCount:3},players:{top:player,bottom:player},hitsData:[]}}; + await page.route('**/api/v1/analysis/77',r=>r.fulfill({json:report})); + await page.route('**/api/v1/videos/77',r=>r.fulfill({json:{code:200,data:{videoInfo:{videoId:77,title:'Synthetic video'},matchSummary:{matchScore:'2:3',unknownRallies:0,totalRallies:1},timelineEvents:[]}}})); + let bottomRelease; + const holdBottom=new Promise(resolve=>{bottomRelease=resolve;}); + await page.route('**/api/v1/videos/77/briefing',async route=>{ + const body=route.request().postDataJSON();briefingCalls.push(body); + if(Object.keys(body).join(',')!=='player')throw new Error('Unexpected client briefing input'); + if(body.player==='bottom')await holdBottom; + await route.fulfill({json:{code:200,data:{videoId:77,player:body.player,text:`## 총평\n${body.player.toUpperCase()}_ONLY\n## 핵심 지표\n- Synthetic data\n## 강점\n- Test\n## 보완점\n- Test\n## 추천 훈련\n- Test`}}}).catch(()=>{}); + }); + await page.goto('http://127.0.0.1:18882/report/77'); + await page.getByText('Bottom Player',{exact:true}).first().waitFor(); + await page.getByText('Top Player',{exact:true}).first().click(); + await page.getByText('TOP_ONLY',{exact:true}).first().waitFor(); + bottomRelease(); + if(await page.getByText('BOTTOM_ONLY',{exact:true}).count())throw new Error('Stale bottom result appeared on top selection'); + if(googleCalls.length)throw new Error('Browser contacted provider directly'); + await page.unroute('**/api/v1/analysis/77'); + await page.route('**/api/v1/analysis/77',r=>r.fulfill({status:404,json:{code:404,errorCode:'RESOURCE_NOT_FOUND',message:'영상을 찾을 수 없습니다.'}})); + await page.reload(); + await page.getByText('영상을 찾을 수 없습니다.',{exact:true}).waitFor(); + if(await page.getByText('분석 준비 중',{exact:false}).count())throw new Error('Foreign video displayed as pending'); + await page.unroute('**/api/v1/analysis/77'); + await page.route('**/api/v1/analysis/77',r=>r.fulfill({status:404,json:{code:404,errorCode:'ANALYSIS_NOT_READY',message:'분석 준비 중'}})); + await page.reload(); + await page.getByText(/분석.*준비|준비.*분석/).first().waitFor(); + await page.evaluate(result=>{window.securityUiResult=result;},{googleDirectRequests:googleCalls.length,briefingRequests:briefingCalls.length,staleResultBlocked:true,errorStatesSeparated:true}); +} diff --git a/tests/security/docker-compose.yml b/tests/security/docker-compose.yml new file mode 100644 index 0000000..b891f63 --- /dev/null +++ b/tests/security/docker-compose.yml @@ -0,0 +1,20 @@ +# Isolated synthetic media test stack. Never use the production Compose project. +services: + minio: + image: minio/minio:latest + command: server /data + environment: + MINIO_ROOT_USER: security-fixture + MINIO_ROOT_PASSWORD: security-fixture-only-password + ports: + - "127.0.0.1:19090:9000" + tmpfs: + - /data + backend: + image: nginx:alpine + frontend: + image: rallytrack-security-frontend:20260916 + build: ../../../frontend + depends_on: [minio, backend] + ports: + - "127.0.0.1:18882:80" diff --git a/tests/security/verify_media_proxy.py b/tests/security/verify_media_proxy.py new file mode 100644 index 0000000..cbe1e47 --- /dev/null +++ b/tests/security/verify_media_proxy.py @@ -0,0 +1,85 @@ +"""Real MinIO + Nginx checks with synthetic credentials and media only; no external packages.""" +from pathlib import Path +import datetime, hashlib, hmac, json, struct, zlib, urllib.parse, urllib.request, urllib.error + +ACCESS = "security-fixture" +SECRET = "security-fixture-only-password" +ROOT = Path(__file__).resolve().parents[3] +DIRECT = "http://127.0.0.1:19090" +PROXY = "http://127.0.0.1:18882" +BUCKET = "/rallytrack-videos" + +def sign(method, base, path, content_type=None): + now = datetime.datetime.now(datetime.timezone.utc) + date, stamp = now.strftime("%Y%m%d"), now.strftime("%Y%m%dT%H%M%SZ") + scope = date + "/us-east-1/s3/aws4_request" + host = urllib.parse.urlsplit(base).netloc + headers = {"host": host} + if content_type: headers["content-type"] = content_type + signed = ";".join(sorted(headers)) + query = {"X-Amz-Algorithm":"AWS4-HMAC-SHA256", "X-Amz-Credential":ACCESS+"/"+scope, + "X-Amz-Date":stamp,"X-Amz-Expires":"3600","X-Amz-SignedHeaders":signed} + encode = lambda value: urllib.parse.quote(value, safe="-_.~") + qs = "&".join(encode(k)+"="+encode(v) for k,v in sorted(query.items())) + canonical = method+"\n"+path+"\n"+qs+"\n"+"".join(k+":"+headers[k]+"\n" for k in sorted(headers))+"\n"+signed+"\nUNSIGNED-PAYLOAD" + msg = "AWS4-HMAC-SHA256\n"+stamp+"\n"+scope+"\n"+hashlib.sha256(canonical.encode()).hexdigest() + key = ("AWS4"+SECRET).encode() + for part in [date,"us-east-1","s3","aws4_request"]: key=hmac.new(key,part.encode(),hashlib.sha256).digest() + signature = hmac.new(key,msg.encode(),hashlib.sha256).hexdigest() + return base+path+"?"+qs+"&X-Amz-Signature="+signature + +def request(method, base, path, data=None, mime=None, extra=None): + url = sign(method,base,path,mime) + headers = dict(extra or {}) + if mime: headers["Content-Type"]=mime + try: + return urllib.request.urlopen(urllib.request.Request(url,data=data,headers=headers,method=method),timeout=10) + except urllib.error.HTTPError as error: + return error + +def guarded(response): + assert response.headers.get("Content-Security-Policy", "").startswith("sandbox;"), response.headers + assert "allow-scripts" not in response.headers["Content-Security-Policy"] + assert "allow-same-origin" not in response.headers["Content-Security-Policy"] + assert response.headers["X-Content-Type-Options"] == "nosniff" + assert "no-store" in response.headers["Cache-Control"] + +if __name__ == "__main__": + created = request("PUT",DIRECT,BUCKET,b"") + assert created.status in (200,409), created.status + clip = (ROOT/"backend/src/test/resources/media/sample.mp4").read_bytes() + # Uploaded directly to MinIO to represent previously stored, unvalidated objects. + def png_chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + thumbnail = (b"\x89PNG\r\n\x1a\n" + png_chunk(b"IHDR", struct.pack(">2I5B", 1, 1, 8, 2, 0, 0, 0)) + + png_chunk(b"IDAT", zlib.compress(b"\x00\x00\x00\xff")) + png_chunk(b"IEND", b"")) + markers = { + "thumbnail.png": (thumbnail, "image/png"), + "legacy.html": (b"safe marker","text/html"), + "legacy.svg": (b'safe marker',"image/svg+xml"), + "disguised.mp4": (b"","video/mp4"), + "sample.mp4": (clip,"video/mp4"), + } + for name,(data,mime) in markers.items(): + assert request("PUT",DIRECT,BUCKET+"/"+name,data,mime).status==200 + urls = {} + for name,(_,mime) in markers.items(): + response=request("GET",PROXY,BUCKET+"/"+name) + assert response.status==200, (name,response.status,response.read()) + guarded(response) + assert response.headers["Content-Disposition"] == ("inline" if mime in ("video/mp4", "image/png") else "attachment") + response.close() + urls[name]=sign("GET",PROXY,BUCKET+"/"+name) + partial=request("GET",PROXY,BUCKET+"/sample.mp4",extra={"Range":"bytes=0-99"}) + assert partial.status==206 and len(partial.read())==100 + guarded(partial) + put=request("PUT",PROXY,BUCKET+"/output.mp4",clip,"video/mp4") + assert put.status==200, (put.status,put.read()) + guarded(put) + assert request("GET",PROXY,BUCKET+"/output.mp4").read()==clip + missing=request("GET",PROXY,BUCKET+"/missing.mp4") + assert missing.status==404; guarded(missing) + with urllib.request.urlopen(PROXY) as app: + assert app.status==200 and app.headers.get("Content-Security-Policy") is None + Path("/tmp/rallytrack-media-urls.json").write_text(json.dumps(urls)) + print("PASS: legacy HTML/SVG attachment, sandbox/nosniff/cache on 200/206/404/PUT, signed GET/Range/PUT, SPA unaffected")