AWS CDK ile URL Kısaltıcı 3. Bölüm: Güvenlik ve Özel Alan Adları
Custom domain, toplu işlem ve URL expiration ile production link shortener servisleri için defense-in-depth güvenlik önlemlerinin implementasyonu.
Production bir link shortener kurmak, kısa URL üretmekten fazlasını gerektirir. Meşru trafiği kaldırırken kötüye kullanımı engelleyen güvenlik önlemleri de gerekir. Link shortener’lar; zararlı içerik dağıtmak, güvenlik filtrelerini atlatmak ve phishing kampanyası yürütmek isteyen kötü niyetli aktörler için çekici hedeflerdir.
Modern link shortener servisleri, input validasyonu, rate limiting, authentication ve gerçek zamanlı monitoring’i birleştiren defense-in-depth koruma gerektirir. Bu yaklaşım hem servisinizi hem de kısaltılmış linklere tıklayan kullanıcıları korur.
1. Bölüm ve 2. Bölüm’de temel yapı ve redirect fonksiyonalitesini oluşturduk. Şimdi bir oyuncak projeyi production servisten ayıran gelişmiş özellikleri ve güvenlik önlemlerini ekleyelim.
Custom Domain Yapılandırması#
Custom domain’i güvenlikten önce ele alıyoruz, çünkü sertifika işinin bekleme süresi en uzun olanı. Pazarlama ekibiniz er ya da geç yourdomain.com/abc123 yerine acme.co/promo gibi markalı kısa URL’ler isteyecek. İşte nasıl çalışır hale getireceğiz:
// lib/custom-domain-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as targets from 'aws-cdk-lib/aws-route53-targets';
export class CustomDomainStack extends Stack {
public readonly customDomainName: apigateway.DomainName;
constructor(scope: Construct, id: string, props: StackProps & {
domainName: string;
hostedZoneId: string;
certificateArn: string; // Pre-created ACM certificate
restApi: apigateway.RestApi;
}) {
super(scope, id, props);
// Import existing hosted zone
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
hostedZoneId: props.hostedZoneId,
zoneName: props.domainName,
});
// Import existing certificate (must be in us-east-1 for API Gateway)
const certificate = acm.Certificate.fromCertificateArn(
this,
'Certificate',
props.certificateArn
);
// Create custom domain name for API Gateway
this.customDomainName = new apigateway.DomainName(this, 'CustomDomain', {
domainName: props.domainName,
certificate: certificate,
securityPolicy: apigateway.SecurityPolicy.TLS_1_2,
endpointType: apigateway.EndpointType.EDGE,
});
// Map the custom domain to our API
this.customDomainName.addBasePathMapping(props.restApi, {
basePath: '', // Root path
});
// Create Route53 alias record
new route53.ARecord(this, 'CustomDomainAlias', {
zone: hostedZone,
target: route53.RecordTarget.fromAlias(
new targets.ApiGatewayDomain(this.customDomainName)
),
});
}
}
Önemli: API Gateway edge-optimized endpoint’ler yalnızca us-east-1’de oluşturulmuş ACM sertifikalarını kabul eder; stack’inizin geri kalanı hangi bölgede olursa olsun. Regional custom domain ise sertifikayı kendi bölgesinden alır.
Bulk İşlem Mimarisi#
Pazarlama ekipleri bulk işlemleri seviyor. Aşağıdaki implementasyon küçük ve büyük batch’leri ayırarak Lambda concurrency limitlerini zorlamadan çalışır:
// lambda/bulk-create.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
import { nanoid } from 'nanoid';
const dynamodb = new DynamoDBClient({});
const sqs = new SQSClient({});
interface BulkCreateRequest {
urls: Array<{
originalUrl: string;
customSlug?: string;
expiresAt?: string;
tags?: string[];
}>;
userId: string;
}
export async function handler(
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> {
try {
const request: BulkCreateRequest = JSON.parse(event.body || '{}');
// Validate batch size to prevent resource exhaustion
if (request.urls.length > 1000) {
return {
statusCode: 400,
body: JSON.stringify({
error: 'Batch boyutu 1000 URL\'yi geçemez'
}),
};
}
// For large batches, use SQS for async processing
if (request.urls.length > 100) {
const jobId = nanoid();
await sqs.send(new SendMessageCommand({
QueueUrl: process.env.BULK_PROCESSING_QUEUE_URL,
MessageBody: JSON.stringify({
jobId,
userId: request.userId,
urls: request.urls,
}),
MessageAttributes: {
jobType: {
DataType: 'String',
StringValue: 'BULK_CREATE'
}
}
}));
return {
statusCode: 202,
body: JSON.stringify({
jobId,
message: 'Bulk oluşturma job\'u kuyruğa eklendi',
estimatedCompletionTime: Math.ceil(request.urls.length / 10) + ' dakika'
}),
};
}
// Process small batches synchronously
const results = await Promise.allSettled(
request.urls.map(async (urlData) => {
const shortCode = urlData.customSlug || nanoid(8);
// Validate URL before creating
if (!isValidUrl(urlData.originalUrl)) {
throw new Error(`Geçersiz URL: ${urlData.originalUrl}`);
}
// Check for malicious content (more on this later)
await validateUrlSafety(urlData.originalUrl);
return await createShortUrl({
shortCode,
originalUrl: urlData.originalUrl,
userId: request.userId,
expiresAt: urlData.expiresAt,
tags: urlData.tags || [],
});
})
);
const successful = results
.filter(result => result.status === 'fulfilled')
.map(result => (result as PromiseFulfilledResult<any>).value);
const failed = results
.filter(result => result.status === 'rejected')
.map(result => (result as PromiseRejectedResult).reason.message);
return {
statusCode: 200,
body: JSON.stringify({
successful: successful.length,
failed: failed.length,
errors: failed,
urls: successful,
}),
};
} catch (error) {
console.error('Bulk create error:', error);
return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' }),
};
}
}
function isValidUrl(url: string): boolean {
try {
const parsedUrl = new URL(url);
return ['http:', 'https:'].includes(parsedUrl.protocol);
} catch {
return false;
}
}
async function validateUrlSafety(url: string): Promise<void> {
// Implementation coming up in security section
// This is where we check against malicious domains
}
URL Expiration ve Scheduling: Zamana Dayalı Özellikler#
Pazarlama kampanyalarının expiration tarihleri olması gerek. İşte pahalı cleanup job’ları çalıştırmadan URL expiration’ın nasıl implement edileceği:
// lambda/redirect-with-expiration.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient, GetItemCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
const dynamodb = new DynamoDBClient({});
export async function handler(
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> {
const shortCode = event.pathParameters?.shortCode;
if (!shortCode) {
return {
statusCode: 404,
body: JSON.stringify({ error: 'Kısa kod bulunamadı' }),
};
}
try {
const response = await dynamodb.send(new GetItemCommand({
TableName: process.env.URLS_TABLE_NAME,
Key: marshall({ shortCode }),
}));
if (!response.Item) {
return {
statusCode: 404,
headers: {
'Content-Type': 'text/html',
},
body: createNotFoundPage(),
};
}
const item = unmarshall(response.Item);
// Check expiration
if (item.expiresAt && new Date(item.expiresAt) < new Date()) {
// URL expired - optionally log this for analytics
await recordExpiredAccess(shortCode, item.userId);
return {
statusCode: 410, // Gone
headers: {
'Content-Type': 'text/html',
},
body: createExpiredPage(item.originalUrl),
};
}
// Check if URL is scheduled for future activation
if (item.activateAt && new Date(item.activateAt) > new Date()) {
return {
statusCode: 404, // Not yet active
headers: {
'Content-Type': 'text/html',
},
body: createNotYetActivePage(),
};
}
// Update click count asynchronously (fire and forget)
updateClickCount(shortCode, event).catch(console.error);
return {
statusCode: 302,
headers: {
Location: item.originalUrl,
'Cache-Control': 'no-cache', // Important for expired URLs
},
body: '',
};
} catch (error) {
console.error('Redirect error:', error);
return {
statusCode: 500,
headers: {
'Content-Type': 'text/html',
},
body: createErrorPage(),
};
}
}
async function recordExpiredAccess(shortCode: string, userId: string): Promise<void> {
// Record that someone tried to access an expired URL
// Useful for analytics and potential abuse detection
try {
await dynamodb.send(new UpdateItemCommand({
TableName: process.env.ANALYTICS_TABLE_NAME,
Key: marshall({
pk: `USER#${userId}`,
sk: `EXPIRED#${shortCode}#${Date.now()}`,
}),
UpdateExpression: 'SET #count = if_not_exists(#count, :zero) + :inc',
ExpressionAttributeNames: {
'#count': 'expiredAccessCount',
},
ExpressionAttributeValues: marshall({
':zero': 0,
':inc': 1,
}),
}));
} catch (error) {
console.error('Failed to record expired access:', error);
}
}
function createExpiredPage(originalUrl: string): string {
return `
<!DOCTYPE html>
<html>
<head>
<title>Link Süresi Dolmuş</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; margin-top: 100px; }
.container { max-width: 500px; margin: 0 auto; }
</style>
</head>
<body>
<div class="container">
<h1>Link Süresi Dolmuş</h1>
<p>Bu linkin süresi dolmuş ve artık kullanılamıyor.</p>
<p>Orijinal hedef: <code>${originalUrl}</code></p>
<a href="/">Ana sayfaya git</a>
</div>
</body>
</html>
`;
}
Güvenlik: Defense in Depth#
Güvenlik, bir link shortener’ın malware dağıtım kanalına dönüşmesini engelleyen şeydir. Aşağıdaki dört katman maliyet sırasına göre çalışır: en ucuz kontroller hatalı girdiyi pahalı olanlar hiç çalışmadan eler.
Katman 1: Input Validation ve URL Güvenliği#
// lambda/url-validator.ts
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall } from '@aws-sdk/util-dynamodb';
const dynamodb = new DynamoDBClient({});
// Malicious domain blacklist (this should be regularly updated)
const MALICIOUS_DOMAINS = new Set<string>([
// Add known malicious domains here
// In production, load this from DynamoDB or Parameter Store
]);
// URL patterns that are commonly abused
const SUSPICIOUS_PATTERNS = [
/bit\.ly/i, // Nested shorteners
/tinyurl\.com/i, // Nested shorteners
/localhost/i, // Local development
/192\.168\./i, // Private networks
/127\.0\.0\.1/i, // Localhost
/10\./i, // Private networks
/172\.16\./i, // Private networks
];
export async function validateUrlSafety(url: string): Promise<{
isValid: boolean;
reason?: string;
}> {
try {
const parsedUrl = new URL(url);
// Check protocol
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return {
isValid: false,
reason: 'Sadece HTTP ve HTTPS URL\'lerine izin veriliyor'
};
}
// Check for private/local addresses
if (SUSPICIOUS_PATTERNS.some(pattern => pattern.test(url))) {
return {
isValid: false,
reason: 'URL şüpheli pattern\'ler içeriyor'
};
}
// Check against malicious domain blacklist
if (MALICIOUS_DOMAINS.has(parsedUrl.hostname.toLowerCase())) {
return {
isValid: false,
reason: 'Domain blacklist\'te'
};
}
// Check against dynamic blacklist in DynamoDB
const blacklistCheck = await dynamodb.send(new GetItemCommand({
TableName: process.env.BLACKLIST_TABLE_NAME,
Key: marshall({
domain: parsedUrl.hostname.toLowerCase()
}),
}));
if (blacklistCheck.Item) {
return {
isValid: false,
reason: 'Domain blacklist\'te'
};
}
// Optional: Check against external reputation services
const reputationCheck = await checkUrlReputation(url);
if (!reputationCheck.isValid) {
return reputationCheck;
}
return { isValid: true };
} catch (error) {
return {
isValid: false,
reason: 'Geçersiz URL formatı'
};
}
}
async function checkUrlReputation(url: string): Promise<{
isValid: boolean;
reason?: string;
}> {
// In production, integrate with services like:
// - Google Safe Browsing API
// - VirusTotal API
// - URLVoid API
// For now, return valid
return { isValid: true };
}
Katman 2: Kimlik Doğrulama ve Yetkilendirme#
// lambda/authorizer.ts
import { APIGatewayTokenAuthorizerEvent, APIGatewayAuthorizerResult } from 'aws-lambda';
import { verify } from 'jsonwebtoken';
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
const dynamodb = new DynamoDBClient({});
interface JWTPayload {
sub: string;
email: string;
role: string;
exp: number;
}
export async function handler(
event: APIGatewayTokenAuthorizerEvent
): Promise<APIGatewayAuthorizerResult> {
try {
const token = event.authorizationToken?.replace('Bearer ', '');
if (!token) {
throw new Error('Token bulunamadı');
}
// Verify JWT token
const decoded = verify(token, process.env.JWT_SECRET!) as JWTPayload;
// Get user details from DynamoDB
const userResponse = await dynamodb.send(new GetItemCommand({
TableName: process.env.USERS_TABLE_NAME,
Key: marshall({ userId: decoded.sub }),
}));
if (!userResponse.Item) {
throw new Error('Kullanıcı bulunamadı');
}
const user = unmarshall(userResponse.Item);
// Check if user is active
if (user.status !== 'ACTIVE') {
throw new Error('Kullanıcı aktif değil');
}
// Generate policy based on user role
const policy = generatePolicy(decoded.sub, 'Allow', event.methodArn, user.role);
// Add user context to be available in Lambda functions
policy.context = {
userId: decoded.sub,
email: decoded.email,
role: user.role,
planType: user.planType || 'free',
};
return policy;
} catch (error) {
console.error('Authorization failed:', error);
throw new Error('Unauthorized');
}
}
function generatePolicy(
principalId: string,
effect: 'Allow' | 'Deny',
resource: string,
role: string
): APIGatewayAuthorizerResult {
const policyDocument = {
Version: '2012-10-17',
Statement: [
{
Action: 'execute-api:Invoke',
Effect: effect,
Resource: resource,
},
],
};
// Role-based permissions
if (role === 'admin') {
// Admins can access all endpoints
policyDocument.Statement[0].Resource = '*';
} else if (role === 'premium') {
// Premium users get access to advanced features
policyDocument.Statement.push({
Action: 'execute-api:Invoke',
Effect: 'Allow',
Resource: resource.replace('/create', '/bulk-create'),
});
}
return {
principalId,
policyDocument,
};
}
Katman 3: Rate Limiting ve Kötüye Kullanım Koruması#
// lambda/rate-limiter.ts
import { APIGatewayProxyResult } from 'aws-lambda';
import {
BatchGetItemCommand,
DynamoDBClient,
TransactWriteItemsCommand,
} from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
const dynamodb = new DynamoDBClient({});
const TABLE_NAME = process.env.RATE_LIMIT_TABLE_NAME!;
interface RateLimitConfig {
requestsPerMinute: number;
requestsPerHour: number;
requestsPerDay: number;
}
const RATE_LIMITS: Record<string, RateLimitConfig> = {
free: {
requestsPerMinute: 10,
requestsPerHour: 100,
requestsPerDay: 1000,
},
premium: {
requestsPerMinute: 100,
requestsPerHour: 1000,
requestsPerDay: 10000,
},
admin: {
requestsPerMinute: 1000,
requestsPerHour: 10000,
requestsPerDay: 100000,
},
};
// One item per user per window. DynamoDB rejects an update expression that
// SETs and ADDs the same attribute, so each counter gets its own item and a
// TTL attribute removes windows that have passed.
const WINDOW_SIZES_MS = {
MINUTE: 60 * 1000,
HOUR: 60 * 60 * 1000,
DAY: 24 * 60 * 60 * 1000,
};
export async function checkRateLimit(
userId: string,
planType: string = 'free',
clientIp?: string
): Promise<{
allowed: boolean;
resetTime?: number;
remainingRequests?: number;
}> {
const config = RATE_LIMITS[planType] || RATE_LIMITS.free;
const now = Date.now();
const windows = [
{ name: 'MINUTE', size: WINDOW_SIZES_MS.MINUTE, limit: config.requestsPerMinute },
{ name: 'HOUR', size: WINDOW_SIZES_MS.HOUR, limit: config.requestsPerHour },
{ name: 'DAY', size: WINDOW_SIZES_MS.DAY, limit: config.requestsPerDay },
].map((window) => {
const bucket = Math.floor(now / window.size);
return {
...window,
windowId: `${window.name}#${bucket}`,
resetTime: (bucket + 1) * window.size,
};
});
try {
// Conditional ADD, so two concurrent invocations cannot both slip past a
// limit. All three windows go in one transaction: every counter moves or
// none of them does. Incrementing them one by one lets a request that the
// daily limit rejects still spend a minute slot and an hour slot on its
// way out, which keeps the user blocked after the daily window resets.
await dynamodb.send(new TransactWriteItemsCommand({
TransactItems: windows.map((window) => ({
Update: {
TableName: TABLE_NAME,
Key: marshall({ userId, window: window.windowId }),
UpdateExpression: 'ADD #count :inc SET #ttl = :ttl',
ConditionExpression: 'attribute_not_exists(#count) OR #count < :limit',
ExpressionAttributeNames: {
'#count': 'requests',
'#ttl': 'expiresAt',
},
ExpressionAttributeValues: marshall({
':inc': 1,
':limit': window.limit,
':ttl': Math.ceil(window.resetTime / 1000) + 60,
}),
},
})),
}));
} catch (error: any) {
if (error.name === 'TransactionCanceledException') {
const blocked = windows.filter(
(_, index) => error.CancellationReasons?.[index]?.Code === 'ConditionalCheckFailed'
);
if (blocked.length > 0) {
return {
allowed: false,
// Every window is evaluated in the same call, so report the one
// that frees up last.
resetTime: Math.max(...blocked.map((window) => window.resetTime)),
remainingRequests: 0,
};
}
}
// Anything else, a TransactionConflict from a concurrent check included,
// is a genuine failure and belongs to the caller.
throw error;
}
// A transaction returns no attribute values, so the remaining budget needs
// a consistent read of the counters we just moved.
const counters = await dynamodb.send(new BatchGetItemCommand({
RequestItems: {
[TABLE_NAME]: {
Keys: windows.map((window) => marshall({ userId, window: window.windowId })),
ConsistentRead: true,
},
},
}));
const counts: Record<string, number> = {};
for (const item of counters.Responses?.[TABLE_NAME] || []) {
const counter = unmarshall(item);
counts[counter.window] = counter.requests;
}
return {
allowed: true,
remainingRequests: Math.min(
...windows.map((window) => window.limit - (counts[window.windowId] || 0))
),
};
}
export function createRateLimitResponse(resetTime: number): APIGatewayProxyResult {
return {
statusCode: 429,
headers: {
'X-RateLimit-Reset': Math.ceil(resetTime / 1000).toString(),
'Retry-After': Math.ceil((resetTime - Date.now()) / 1000).toString(),
},
body: JSON.stringify({
error: 'Rate limit aşıldı',
message: 'Çok fazla request. Lütfen daha sonra tekrar deneyin.',
resetTime: new Date(resetTime).toISOString(),
}),
};
}
Katman 4: AWS WAF Koruması#
// lib/waf-stack.ts
import { Fn, Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as wafv2 from 'aws-cdk-lib/aws-wafv2';
import * as logs from 'aws-cdk-lib/aws-logs';
export class WAFStack extends Stack {
public readonly webAcl: wafv2.CfnWebACL;
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
// Create CloudWatch log group for WAF logs.
// WAF only accepts log group names that start with "aws-waf-logs-".
const logGroup = new logs.LogGroup(this, 'WAFLogGroup', {
logGroupName: 'aws-waf-logs-link-shortener',
retention: logs.RetentionDays.ONE_MONTH,
});
this.webAcl = new wafv2.CfnWebACL(this, 'LinkShortenerWAF', {
scope: 'CLOUDFRONT', // Use REGIONAL for ALB/API Gateway
defaultAction: { allow: {} },
rules: [
// Rule 1: AWS Managed Rules - Core Rule Set
{
name: 'AWSManagedRulesCore',
priority: 1,
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesCommonRuleSet',
},
},
overrideAction: { none: {} },
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'AWSManagedRulesCore',
},
},
// Rule 2: Rate limiting for URL creation
{
name: 'RateLimitCreation',
priority: 2,
statement: {
rateBasedStatement: {
limit: 1000, // requests per 5-minute window
aggregateKeyType: 'IP',
scopeDownStatement: {
byteMatchStatement: {
searchString: '/create',
fieldToMatch: { uriPath: {} },
textTransformations: [
{ priority: 0, type: 'LOWERCASE' },
],
positionalConstraint: 'CONTAINS',
},
},
},
},
action: {
block: {
customResponse: {
responseCode: 429,
customResponseBodyKey: 'RateLimitExceeded',
},
},
},
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'RateLimitCreation',
},
},
// Rule 3: Block known bot networks
{
name: 'AWSManagedRulesBot',
priority: 3,
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesBotControlRuleSet',
},
},
overrideAction: { none: {} },
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'AWSManagedRulesBot',
},
},
// Rule 4: IP reputation list
{
name: 'AWSManagedRulesIPReputation',
priority: 4,
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesAmazonIpReputationList',
},
},
overrideAction: { none: {} },
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'AWSManagedRulesIPReputation',
},
},
],
customResponseBodies: {
RateLimitExceeded: {
contentType: 'APPLICATION_JSON',
content: JSON.stringify({
error: 'Rate limit aşıldı',
message: 'IP adresinden çok fazla request. Lütfen daha sonra tekrar deneyin.',
}),
},
},
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'LinkShortenerWAF',
},
});
// Enable logging
new wafv2.CfnLoggingConfiguration(this, 'WAFLogging', {
resourceArn: this.webAcl.attrArn,
// logGroupArn ends with ":*", which WAF rejects as a destination
logDestinationConfigs: [Fn.select(0, Fn.split(':*', logGroup.logGroupArn))],
loggingFilter: {
defaultBehavior: 'KEEP',
filters: [
{
behavior: 'DROP',
conditions: [
{
actionCondition: {
action: 'ALLOW',
},
},
],
requirement: 'MEETS_ANY',
},
],
},
});
}
}
Gelişmiş Analytics ve Monitoring#
Kötü aktörleri engellemek işin yarısı. Diğer yarısı, sistem çalışırken içeride ne olduğunu görebilmek:
// lambda/security-monitor.ts
import { CloudWatchClient, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch';
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';
const cloudwatch = new CloudWatchClient({});
const sns = new SNSClient({});
export interface SecurityEvent {
type: 'RATE_LIMIT_EXCEEDED' | 'MALICIOUS_URL_BLOCKED' | 'SUSPICIOUS_BULK_REQUEST';
userId?: string;
clientIp: string;
userAgent?: string;
details: Record<string, any>;
timestamp: number;
}
export async function recordSecurityEvent(event: SecurityEvent): Promise<void> {
try {
// Send metric to CloudWatch
await cloudwatch.send(new PutMetricDataCommand({
Namespace: 'LinkShortener/Security',
MetricData: [
{
MetricName: event.type,
Value: 1,
Unit: 'Count',
Timestamp: new Date(event.timestamp),
Dimensions: [
{
Name: 'EventType',
Value: event.type,
},
...(event.userId ? [{
Name: 'UserId',
Value: event.userId,
}] : []),
],
},
],
}));
// For critical events, send SNS alert
if (shouldAlertOn(event)) {
await sns.send(new PublishCommand({
TopicArn: process.env.SECURITY_ALERTS_TOPIC_ARN,
Subject: `Güvenlik Alarmı: ${event.type}`,
Message: JSON.stringify({
eventType: event.type,
timestamp: new Date(event.timestamp).toISOString(),
clientIp: event.clientIp,
userId: event.userId,
details: event.details,
}, null, 2),
}));
}
console.log('Security event recorded:', {
type: event.type,
userId: event.userId,
clientIp: event.clientIp,
timestamp: event.timestamp,
});
} catch (error) {
console.error('Failed to record security event:', error);
// Don't throw - security monitoring failures shouldn't break the main flow
}
}
function shouldAlertOn(event: SecurityEvent): boolean {
// Define which events should trigger immediate alerts
const alertEvents: SecurityEvent['type'][] = [
'MALICIOUS_URL_BLOCKED',
'SUSPICIOUS_BULK_REQUEST',
];
return alertEvents.includes(event.type);
}
// Create a dashboard for security metrics
export async function createSecurityDashboard(): Promise<void> {
// This would be part of your CDK infrastructure code
// Implementation depends on your specific monitoring needs
}
Güvenlik Katmanlarının API’ye Entegrasyonu#
Tüm bu güvenlik katmanları bir production endpoint’te şöyle bir araya geliyor:
// lambda/secure-create-url.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { validateUrlSafety } from './url-validator';
import { checkRateLimit, createRateLimitResponse } from './rate-limiter';
import { recordSecurityEvent } from './security-monitor';
import { nanoid } from 'nanoid';
export async function handler(
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> {
const startTime = Date.now();
try {
// Extract user context from authorizer
const userId = event.requestContext.authorizer?.userId;
const planType = event.requestContext.authorizer?.planType || 'free';
const clientIp = event.requestContext.identity?.sourceIp;
if (!userId) {
return {
statusCode: 401,
body: JSON.stringify({ error: 'Kimlik doğrulama gerekli' }),
};
}
// Check rate limits first (fail fast)
const rateLimitCheck = await checkRateLimit(userId, planType, clientIp);
if (!rateLimitCheck.allowed) {
await recordSecurityEvent({
type: 'RATE_LIMIT_EXCEEDED',
userId,
clientIp: clientIp!,
userAgent: event.headers['User-Agent'],
details: { planType, resetTime: rateLimitCheck.resetTime },
timestamp: Date.now(),
});
return createRateLimitResponse(rateLimitCheck.resetTime!);
}
// Parse and validate request
const request = JSON.parse(event.body || '{}');
if (!request.originalUrl) {
return {
statusCode: 400,
body: JSON.stringify({
error: 'originalUrl gerekli',
remainingRequests: rateLimitCheck.remainingRequests
}),
};
}
// Validate URL safety
const safetyCheck = await validateUrlSafety(request.originalUrl);
if (!safetyCheck.isValid) {
await recordSecurityEvent({
type: 'MALICIOUS_URL_BLOCKED',
userId,
clientIp: clientIp!,
userAgent: event.headers['User-Agent'],
details: {
originalUrl: request.originalUrl,
reason: safetyCheck.reason
},
timestamp: Date.now(),
});
return {
statusCode: 400,
body: JSON.stringify({
error: 'URL doğrulama başarısız',
reason: safetyCheck.reason,
remainingRequests: rateLimitCheck.remainingRequests
}),
};
}
// Create short URL
const shortCode = request.customSlug || nanoid(8);
// TODO: Save to DynamoDB (implementation from previous parts)
const shortUrl = await createShortUrl({
shortCode,
originalUrl: request.originalUrl,
userId,
expiresAt: request.expiresAt,
tags: request.tags || [],
});
const responseTime = Date.now() - startTime;
// Record successful creation
console.log(`URL başarıyla oluşturuldu: ${shortCode} -> ${request.originalUrl} (${responseTime}ms)`);
return {
statusCode: 201,
headers: {
'X-RateLimit-Remaining': rateLimitCheck.remainingRequests?.toString() || '0',
'X-Response-Time': responseTime.toString(),
},
body: JSON.stringify({
shortCode,
shortUrl: `${process.env.DOMAIN_NAME}/${shortCode}`,
originalUrl: request.originalUrl,
createdAt: new Date().toISOString(),
expiresAt: request.expiresAt,
remainingRequests: rateLimitCheck.remainingRequests,
}),
};
} catch (error) {
console.error('Error creating short URL:', error);
const responseTime = Date.now() - startTime;
return {
statusCode: 500,
headers: {
'X-Response-Time': responseTime.toString(),
},
body: JSON.stringify({
error: 'Internal server error',
requestId: event.requestContext.requestId
}),
};
}
}
Temel Güvenlik Konuları#
Production’a hazır bir link shortener güvenliği kurarken birkaç kritik nokta öne çıkıyor:
1. Güvenliği Baştan Tasarlamak Güvenlik önlemlerini en baştan tasarlamak, sonradan eklemekten daha etkilidir. Erken entegrasyon mimari çatışmaları önler ve ölçeklenirken biriken teknik borcu azaltır.
2. Serverless Rate Limiting Zorlukları Geleneksel token bucket algoritmaları, invocation’lar arasında durum tutulmadığı için serverless ortamlara doğrudan uymaz. Zaman pencereli DynamoDB atomik counter’ları daha iyi sonuç verir. Pencereleri tek bir transaction’da toplamak counter’ları tutarlı tutar ve her kontrolün harcadığı write capacity’yi ikiye katlar; bu yüzden tüketimi izlemek gerekir.
3. Adaptif URL Doğrulama Kötü niyetli domain listeleri, tehdit aktörleri yeni domain kaydettikçe sürekli güncellenmek zorunda. Blocklist’i hızlı güncelleyebilen bir sistem kurmak, ilk günden eksiksiz bir liste hedeflemekten daha sürdürülebilir.
4. Pattern Tabanlı Monitoring Tek tek güvenlik olayları sınırlı bilgi verir. Monitoring, davranış pattern’lerini yakalamaya odaklanmalı: tek bir IP’den tekrarlayan istekler, olağandışı redirect hacimleri veya yeni açılmış hesaplardan gelen bulk işlemler.
5. Custom Domain Planlaması Markalı kısa URL’ler servis olgunlaştıkça neredeyse her zaman isteniyor. Custom domain desteğini ilk sürümde eklemek, çalışan bir sisteme sonradan eklemekten çok daha kolay.
Sonraki Adımlar#
Yukarıdaki dört katman tek bölgeli bir API varsayıyor. Multi-region’a geçerseniz ikisi değişir: rate limit counter’ları DynamoDB global table’a taşınır ve conditional write’lar bölgeler arasında atomik olmaktan çıkar, ayrıca deploy ettiğiniz her bölge kendi REGIONAL web ACL’ine ihtiyaç duyar.
4. Bölüm production deployment stratejilerini, issue’ları gerçekten debug etmeye yarayan monitoring’i ve buraya kadar kurulan yapının maliyet optimizasyonunu ele alıyor. Ayrıca trafik spike yönetimi, veritabanı ölçekleme pattern’leri ve güvenilir production görünürlüğü sağlayan monitoring konfigürasyonlarına giriyor.
Kaynaklar#
- AWS WAF Geliştirici Kılavuzu (yeni sekmede açılır) - API’leri korumak için web ACL’leri, oran tabanlı kurallar ve yönetilen kural gruplarını yapılandırmaya yönelik resmi belgeleme.
- API Gateway’de REST API’ler için Özel Alan Adları (yeni sekmede açılır) - API Gateway uç noktaları için ACM sertifikaları ile özel alan adı kurulum kılavuzu.
- AWS Certificate Manager Genel Bakış (yeni sekmede açılır) - CloudFront ve API Gateway ile kullanılan SSL/TLS sertifikalarını sağlama ve yönetme belgeleri.
- Amazon CloudFront Geliştirici Kılavuzu (yeni sekmede açılır) - CDN dağıtımlarını, önbellekleme davranışlarını ve kenar güvenliğini yapılandırma referansı.
- DynamoDB Global Tables (yeni sekmede açılır) - Küresel olarak dağıtılmış tablolar için çok bölgeli, çok aktif replikasyon belgeleri.
- AWS CDK v2 API Referansı (yeni sekmede açılır) - ACM, API Gateway, CloudFront ve WAF CDK modüllerini kapsayan construct kütüphane referansı.
AWS CDK Link Kısaltıcı: Sıfırdan Production'a
AWS CDK, Node.js Lambda ve DynamoDB ile production-grade bir link kısaltma servisi kurulumu hakkında 5 bölümlük kapsamlı seri. Gerçek production hikayeleri, performans optimizasyonu ve maliyet yönetimi dahil.
Bu serideki tüm yazılar
İlgili yazılar
Gelişmiş Amazon Cognito teknik rehberi: özel auth akışları, federation, multi-tenancy, migration stratejileri ve CDK ile production-grade güvenlik.
aws · authentication · serverless +6
Yönlendirme motoru, analytics toplama ve API Gateway yapılandırması: günlük milyonlarca yönlendirme için performans optimizasyonları ve debugging stratejileri.
aws-cdk · lambda · api-gateway +5
Bir kodlama ajanının evi olarak devcontainer, Codespaces ve AWS Lambda MicroVM: her basamak ne katıyor ve ajanı laptoptan çıkarmak ne zaman kazandırıyor.
lambda · claude-code · ai-tools +5
CDK TypeScript Lambda için 9 bundler ve 3 cdk synth runner'ının ölçümlü karşılaştırması; her katman için varsayılan ve onu seçtiren kural.
aws-cdk · lambda · typescript +3
AWS Cognito ve Verified Permissions ile SaaS yetkilendirmeyi Cedar politikaları, çok kiracılı desenler, JWT akışı ve maliyet analiziyle TypeScript'te kurun.
authorization · aws · authentication +4